# TuneVault Changelog

- 2026-07-10: FEAT — Auto-issue a self-hosted license key on confirmed payment (replaces manual-only
  minting via the admin /issue endpoint). routes/payments.js activatePlan() now mints a license as Step 2,
  between creditUser() and the welcome email: it first calls the new db/licenses.js findActiveByUserId(userId,
  planTier) and reuses that key if an active license already exists (idempotency — webhook retries and
  double-payments never mint duplicates); otherwise generateLicenseKey(planTier) + licensesDb.createLicense()
  with tier-aware max_connections (config/pricing.js CONN_PRICES[planTier].max_conns, fallback 5),
  max_health_checks_per_month = NULL (unlimited — the per-month cap is intentionally not enforced), and no
  expiry. The whole block is wrapped in try/catch and is non-fatal (same contract as the email step): a mint
  failure logs and leaves licenseKey null, the plan stays unlocked, and the key can be minted manually later.
  The minted key is threaded into services/welcome-email.js sendWelcomeEmail({..., licenseKey}), which renders
  a new "Your self-hosted license key" section in both the HTML template and plain-text body — the key,
  releases.tunevault.app download link, TUNEVAULT_LICENSE_KEY .env line, ./setup.sh step, and a tier-aware
  connection-limit line — omitted silently when licenseKey is null. PLAN_DISPLAY in welcome-email.js was
  extended from 4 legacy entries (starter/growth/scale/custom) to cover all current per-connection tiers
  (professional/business/enterprise/managed_services, checks strings mirroring PLAN_CHECKS in db/payments.js:
  starter = 50, all per-conn tiers Unlimited) plus retained legacy tiers, so post-payment emails no longer
  fall back to the raw tier slug with a "?" check count. routes/license.js POST /issue: the /issue default
  max_connections is now tier-aware (CONN_PRICES[plan_tier].max_conns ?? 5, was a hardcoded 5) and the
  max_health_checks_per_month default is now NULL (was 100). Migration 1783600000000 drops NOT NULL + DEFAULT
  on self_hosted_licenses.max_health_checks_per_month (was NOT NULL DEFAULT 100) so NULL/unlimited can be
  stored. Commit c09e2a7.

- 2026-07-09: FEAT — TuneOps ticket lifecycle + bulk actions + recurrence UI. (1) Recurrence: when a health
  check finding matches an already-OPEN ticket (same canonical_key + connection), the engine now bumps
  occurrence_count + last_seen_at (and worsens severity only if escalated) instead of ignoring it; a new
  ticket is created only when no open ticket exists. New tuneops_tickets columns occurrence_count (default 1)
  + last_seen_at (default NOW()) via ensureColumns(); new db/tuneops-tickets.js recordOccurrence();
  services/tuneops-ticket-engine.js calls it on the open-match path. (2) Bulk actions: new
  PATCH /api/tuneops/tickets/bulk (routes/tuneops.js — registered BEFORE /tickets/:ticket_number so "bulk"
  isn't captured as an id) applies acknowledge/resolve/defer to many tickets in one call, reusing the same
  per-ticket db helpers + status guards; dedupes + caps at 200; returns {updated, skipped, counts}. Bulk
  resolve accepts an optional shared resolution_notes. public/tuneops.html: per-row + select-all checkboxes
  (new grid column, mobile-aware), a bulk action bar shown on selection, bulk resolve routes through the
  resolve modal (note optional). (3) Ticket detail: Occurrences info row (First seen / Last seen / "Seen N
  times across health checks") + an amber age-warning banner for OPEN/REOPENED tickets un-acknowledged >4h,
  escalating to red tone >24h. Commits 8f65dfb, ecfbf16, ba0e144, 25c2f83.

- 2026-07-09: FIX — TuneOps tickets never created on the no-AI path + first-run race. runAIAnalysis returned
  early at `if (!AI_ENABLED) return` (AI_ENABLED = !!OPENAI_API_KEY), and persistCheckResults + delta + ADOP +
  processHealthCheckFindings all lived AFTER that return — so instances without OPENAI_API_KEY never persisted
  check_results or created tickets, regardless of auto_create_tickets. Extracted these into
  runHealthCheckSideEffects() called from BOTH the no-AI branch (before return) and the AI path; persist is
  now awaited BEFORE processHealthCheckFindings (closes the race where the engine read check_results before
  the write landed). Added ON/skip logs. Commit 28f5657.

- 2026-07-09: FEAT — "Auto-create TuneOps tickets" per-connection toggle. connection_schedules gains
  auto_create_tickets BOOLEAN DEFAULT TRUE (ensureColumns); the completion handler skips ticket creation only
  when explicitly false (default TRUE on missing row / lookup error). routes/schedules.js PUT + db/schedules.js
  upsertSchedule store it. public/connections.html: checkbox inside the monitoring #mon-details panel (visible
  when scheduling enabled) + a toggle by the slide-out Run Health Check button (POST /api/health-checks accepts
  auto_create_tickets and persists it; opt-out upserts, opt-in updates existing only). Commits 5b0da74, 7d9f22d.

- 2026-07-09: FEAT — Immediate health check when autonomous monitoring is first enabled. routes/schedules.js
  PUT fires one health check via the scheduler's runner (app.locals.runHealthCheckForConnection) on the
  enable transition (new schedule / disabled→enabled), gated on the same free-tier guard the cron applies
  (app.locals.isScheduledRunAllowed); next_run_at = NOW()+cadence so no double-run. UI shows "Monitoring
  enabled — running first check now…". Commits 99712c4, 673b4b3.

- 2026-07-09: FEAT — Global proxy update banner on /connections. Dismissible banner atop the Servers page
  when any connected agent runs an outdated proxy (uses proxy_upgrade_available / agent_version /
  latest_proxy_version already returned by the API; no backend change). Self-hides once agents upgrade;
  dismiss sticks for the session via a bannerDismissed flag. public/connections.html only. Commits b454ec7,
  8e4646c, c66b731.

- 2026-07-09: FEAT — Guided onboarding on the dashboard. New routes/onboarding.js (GET /api/onboarding/progress
  derives step completion from oracle_connections, health_checks, analytics_events, connection_schedules;
  POST /dismiss). user_preferences gains onboarding_dismissed (ensureColumns). public/onboarding-checklist.js:
  welcome modal with Required (add connection, run health check) + Optional (DBA Agent, security score,
  TuneBot, download report, monitoring) steps; auto-opens for first-timers, otherwise a "Getting started
  guide" trigger button. Step-completion event emitters wired fire-and-forget: tunebot_query (routes/tunebot.js),
  dba_agent_query (routes/dba-agent.js), page_view{page:security} (routes/security.js, optional-auth on the
  /security→/trust redirect), report_downloaded (routes/reports.js pdf downloads). Commits b3187c6, b7a892a,
  df69b8b, 18e3f20.

- 2026-07-09: FEAT — oracle-proxy.py 3.21.16. (1) db_listener_{status,start,stop,reload} resolve the listener
  name as $ORACLE_SID → LISTENER_<SID> → LISTENER (EBS listeners are SID-named, e.g. EBSCDB). (2) New instance
  ops db_shutdown_normal, db_shutdown_transactional, db_startup_restrict, db_startup_force (proxy _CTRL_ALLOWED
  + _DB_OP_CATALOG; db-ops-executor.js catalog + sysdba map). (3) New read-only fmw_opatch_forms_home (Forms/
  Reports 10.1.2 OPatch inventory, EBS 12.2 dual-fs $APPL_TOP/../fs1/EBSapps/10.1.2). (4) fmw_opatch_wls_home
  probes $FMW_HOME/wlserver_10.3 first (EBS 12.2 default WLS 10.3.6). Version bumped in 4 files. Commit 239836e.

- 2026-07-09: FEAT — Landing self-hosted demo videos + CSP. Replaced the single hero iframe with a thumbnail
  grid of YouTube demo cards (added youtube.com to CSP frameSrc for the iframe, then removed it when switching
  to thumbnail links); grew to a 5-card grid (added "Autonomous Monitoring — No SSH Needed", centered 5th card
  at card width). public/index.html + middleware/security.js. Commits db588d6, 56c0c2c, a8d1b94, 8065065, 525bcaf.

- 2026-07-09: RELEASE — Self-hosted v1.2.2. Built tunevault-selfhosted-v1.2.2.zip and uploaded to Cloudflare
  R2 (releases.tunevault.app); public/download.html release refs + zip_downloaded analytics version bumped
  v1.2.1 → v1.2.2. Commit 0d9b3da.

- 2026-07-08: FEAT — Real self-hosted license server (replaces honor system). New self_hosted_licenses table
  (migration) + lib/license-key.js (generateLicenseKey → TV-<tier>-XXXX-… ) + db/licenses.js CRUD.
  routes/license.js: GET /status (cloud short-circuit; self-hosted validates its key over HTTP against the
  license server), public POST /validate (DB lookup + status/expiry/instance-lock checks), admin POST /issue,
  admin GET /api/admin/licenses. server.js: validateSelfHostedLicense() in the startup chain (exit(1) on
  missing/invalid key; tolerate unreachable server → offline mode); connection-limit enforcement (402) on
  POST /api/connections + the wizard draft route. .env.example documents TUNEVAULT_LICENSE_SERVER_URL +
  TUNEVAULT_INSTANCE_ID. Commits cb508c6, 968e4ae.

- 2026-07-08: FEAT — Landing leads with self-hosted. index.html hero reframed around "no data leaves your
  network" (badge, subheadline, CTAs → Download Self-Hosted primary, Self-Hosted edition badge); tagline
  "Diagnose. Tune. Control. See everything. Fix anything." restored (two lines). vs-oem/compare/download.html
  lead with Download Self-Hosted; features/pricing/blog gain self-hosted sections. Commits a4fe748, 043d0bd,
  6e292d7 (+ db588d6 above).

- 2026-07-08: SECURITY/CI — Moved CI/test credential literals to env vars + GitHub Secrets
  (.github/workflows/e2e-smoke.yml, e2e-oracle-xe-full.yml, tests/api-delete-connection.js, .env.example);
  temporarily disabled the e2e-smoke job (if: false) to stop failure emails until CI secrets are set.
  Commits 1b79b5e, fdc78a6.

- 2026-07-07: FEAT — Track self-hosted release downloads → analytics_events. public/download.html: the
  "Download Latest Release" button (id=download-zip-btn) fires a fetch(keepalive) POST to /api/analytics/event
  with { event_name:'zip_downloaded', properties:{version:'1.2.1', filename:'tunevault-selfhosted-v1.2.1.zip'} }
  on click — non-blocking (try/catch, never preventDefault; the download always proceeds), keepalive so the
  request survives the download navigation, mirroring tv-tracker.js. routes/analytics.js handleEventPost: now
  accepts event_name as a fallback for event (const eventKey = event || event_name) so raw client POSTs AND
  tv-tracker.js (event) both resolve; the checkout_succeeded→checkout_completed normalisation uses eventKey;
  'zip_downloaded' added to ALLOWED_EVENTS. Frontend commit 3bd5ac0 + backend commit ffdab55.

- 2026-07-06: FIX — Pricing-tier rename fallout across the upgrade/checkout flow. config/pricing.js had
  renamed 'team'→'professional' and added 'managed_services', but routes + DB lagged, breaking every paid
  upgrade. (1) routes/payments.js POST /api/payments/change-plan (upgrade): VALID_PAID_TIERS/TIER_ORDER now
  canonical [starter,professional,business,enterprise,managed_services] + a legacy-alias normalizer
  (team→professional, individual→starter via canonicalTier) — fixes "Invalid target_plan for upgrade". The
  proration base price is now conn×min_conns (the tier's minimum monthly commitment), NOT the bare per-conn
  rate: per-conn rates DROP as tiers rise (volume pricing) so newPrice−unusedCredit went negative and hit the
  $1 floor → 100 cents × 85 (USD_TO_INR) = ₹85 at checkout. (2) POST /api/create-order: professional +
  managed_services added to the local PER_CONN_TIERS and the double-charge paidTiers list; the canonical-price
  cross-check clamp now uses each tier's own [min_conns,max_conns] (a hardcoded [1,200] falsely rejected
  managed_services, min 301, and high-conn enterprise). (3) config/pricing.js: restored
  ENTERPRISE_VOLUME_THRESHOLDS + enterpriseVolumeDiscount (150+ conns→10%, 200+→15%), applied it inside
  calcPerConnAmount with enterprise quarterly (3-month) annual billing, and EXPORTED it — payments.js
  referenced pricingConfig.enterpriseVolumeDiscount but it was undefined (lost in an earlier edit), throwing
  before the try/catch → uncaught 500 on every enterprise create-order. (4) db/payments.js PLAN_CHECKS gains
  professional + managed_services (-1 = unlimited) so a completed upgrade grants checks, not 0. (5) Migration
  1782720000000 widens the plan_tier CHECK constraint on payments, subscriptions, and user_credits to include
  professional + managed_services (kept all legacy values) — a 23514 check-constraint violation at the
  createPaymentRecord INSERT was the actual "Failed to process plan change" on Professional/Managed upgrades;
  applied to the live Neon DB. (6) change-plan MAX_ORDER_INR_PAISE (₹50,000 = 5,000,000 paise) ceiling: checks
  proratedDeltaInr (the exact INR paise sent to Razorpay) BEFORE the rzp.orders.create call and returns a clear
  "This upgrade amount (₹X) exceeds our online payment limit of ₹50,000. Please contact kiran@tunevault.app…"
  message instead of a raw Razorpay 400 ("Invalid plan change request"). Business/Enterprise/Managed prorated
  deltas (₹53k/₹122k/₹305k) exceed the live account's per-transaction cap; Professional (₹15.6k) still goes
  through online. (An earlier attempt guarded proratedDelta in USD cents vs MAX_ORDER_CENTS $49,900, which
  never fired for these amounts.) Commits 9fd21a4, acfec7b, b83c7d3, fda00f6, 711e847.

- 2026-07-06: UI — Login page polish (public/login.html, frontend only). Added a TV monogram logo
  (public/images/tv-logo.svg — orange #f97316 rounded square, white "TV", transparent bg) centered ~80px above
  the heading; heading "Sign in to TuneVault" → "Sign in or create your account" (so first-time visitors don't
  think they need an existing account); subheading "Oracle operational intelligence — health checks and
  monitoring" → "Oracle Database & EBS 12.2 monitoring for enterprise DBAs"; small muted helper text under the
  magic-link email input ("Check your spam or junk folder if the link doesn't arrive within 2 minutes.") and
  under the Google button ("Signing in with Google is the fastest way to get started"). Commits 9a1e39d,
  ab773de, a8dc5c3.

- 2026-07-04: FIX — DBA Agent / SQL Console run_sql on db-tier proxy connections with a NULL host
  (proxy 3.21.14 → 3.21.15). Db-tier proxy connections store host=NULL (the agent connects to Oracle
  locally). routes/console.js executeSQL forwarded that null host verbatim to /api/run_sql, and the proxy's
  _resolve_db_host returned params.get("host","localhost") — which does NOT substitute for a present-but-null
  value — so cx_Oracle.makedsn(None, …) raised "argument of type 'NoneType' is not iterable", surfaced by the
  DBA Agent as "Query error: …". (The regular health check already dodged this via host: host || 'localhost'
  in server.js.) Fix: (1) routes/console.js — default host to localhost in BOTH the run_sql and explain-plan
  proxy paths: (pairedDb ? pairedDb.host : connParams.host) || 'localhost'. (2) oracle-proxy.py
  _resolve_db_host — params.get("host") or "localhost", so a null/empty host is handled at the source for
  every /api/run_sql + /api/healthcheck caller (covers ebs-ops.js too). Node fix is immediate; proxy fix
  lands via agent auto-upgrade.

- 2026-07-03: FIX/CONTENT — Track blog source files + correct the EBS→OCI app-tier clone steps.
  (1) .gitignore: data/oracle-docs/ was fully ignored (for the large RAG PDFs), but scripts/seed-blog.js
  reads 9 blog source files (7 .md + 2 .html) from it — so on Render those files were absent and the seeder
  would ENOENT at load (npm run seed-blog + admin POST /api/admin/seed-blog). Changed the rule to
  `data/oracle-docs/*` + `!*.md` + `!*.html` so PDFs stay ignored but the blog sources are committed. The 9
  files are now tracked. (2) blog-ebs-oci-zdm.md: replaced the AutoConfig-first app-tier step with the
  correct Rapid Clone sequence (adpreclone.pl appsTier on source → adcfgclone.pl appsTier on target, which
  reconfigures the context file and runs AutoConfig internally; dualfs note), added a "Rapid Clone for the
  App Tier" section (stages, Rapid Clone vs manual tar+AutoConfig, how it complements ZDM for the DB layer).
  Live DB blog_posts row ebs-cloning-to-oci updated (8,265→13,145 chars; coming_soon already false).

- 2026-07-03: CONTENT — Published 7 more blog articles from data/oracle-docs markdown files and made
  scripts/seed-blog.js source them from those files. Live DB blog_posts updated (content + coming_soon=false)
  for: oracle-19c-upgrade-from-12c, ebs-12-2-adop-patching-failures, oracle-data-guard-switchover-failover,
  oracle-rac-troubleshooting, oracle-tablespace-management, ebs-performance-tuning-cm-opp-wf, ebs-cloning-to-oci
  (from blog-19c-upgrade / blog-adop-failures / blog-data-guard / blog-rac-troubleshooting / blog-tablespace /
  blog-ebs-performance / blog-ebs-oci-zdm .md respectively). seed-blog.js: the 7 inline *_CONTENT constants
  (UPGRADE_19C/TABLESPACE_MGMT/ADOP/RAC/DATAGUARD/EBS_PERF/EBS_OCI) now read their .md file via
  fs.readFileSync (like the AWR/SECURITY pair), so those files are the single source of truth and a re-seed
  no longer reverts the DB. No coming_soon=true rows remain. (ZDM/PERF_CRISIS/EBS_CLONE stay inline — no md
  source.) No app-code changes; renderer already handles fenced code with blank lines.

- 2026-07-03: FEAT/FIX — Published the Security-Hardening + AWR/ASH blog articles from data/oracle-docs.
  (1) routes/blog.js markdownToHtml(): fenced code blocks are now stashed to placeholders BEFORE the
  blank-line paragraph split (and restored after), so a code block containing an internal blank line no
  longer gets split — previously it leaked <br> tags and an unclosed <pre> into the output. Benefits every
  DB-backed post. (2) scripts/seed-blog.js: AWR_ASH_CONTENT + SECURITY_CONTENT are no longer inline
  markdown literals — a new htmlToMarkdown() converts data/oracle-docs/{awr-ash-guide,security-hardening}.html
  (drops the leading <h1>, since the page renders post.title) so those two HTML files are the single source
  of truth. (3) Live DB: blog_posts.content for oracle-awr-ash-analysis (→13.2k chars) and
  oracle-security-hardening (→10.6k) updated from the converted HTML (placeholders replaced); coming_soon
  already false. Note: seed-blog runs manually / via POST /api/admin/seed-blog, NOT on deploy.

- 2026-07-03: FIX — ebs-patches.html UI (frontend only). (1) DB ORACLE_HOME card now hidden when the
  selected connection is server_type='apps' (belongs to the DB tier; 'both'/'db'/null still show it).
  (2) FMW WLS Home card relabelled from "Oracle_Home" and its command/description updated to reflect the
  real EBS 12.2 layout: default WLS 10.3.6 at $FMW_HOME/wlserver_10.3 (BSU-patched) vs upgraded WLS 12.2.1.x
  at $FMW_HOME/Oracle_Home (OPatch); the Run button probes both. No backend/proxy changes.

- 2026-07-03: FIX — WLS home probing validated against a live EBS 12.2.12 instance (proxy 3.21.13 → 3.21.14).
  Real homes confirmed: $FMW_HOME/{oracle_common,webtier,wlserver_10.3}; WLS is 10.3.6 (BSU/Smart-Update
  patched, not OPatch); 10.1.2 Forms home at $APPS_BASE/fs1/EBSapps/10.1.2. oracle_common + webtier ops were
  already exact. fmw_opatch_wls_home hardcoded $FMW_HOME/Oracle_Home (12.2.1.x-only naming) → on the common
  10.3.6 stack it returned OPATCH_NOT_FOUND AND produced a spurious "every oracle_common patch missing from
  Oracle_Home" mismatch. Now: oracle-proxy.py fmw_opatch_wls_home probes $FMW_HOME/{Oracle_Home,wlserver} and,
  when neither has OPatch, emits WLS_BSU_MANAGED if wlserver_10.3 / bsu.sh present (informational ok state).
  routes/patches.js parseOpatchInventory surfaces agent sentinels (FMW_HOME_NOT_SET/OPATCH_NOT_FOUND/
  MW_HOME_NOT_SET/BSU_NOT_FOUND/WLS_BSU_MANAGED) instead of misrendering as "No patches found"; fmw-inventory
  only computes the mismatch when BOTH homes read cleanly (mismatch.comparable) and returns a reason otherwise.
  public/ebs-patches.html: new info note renders the non-comparable reason (e.g. BSU-managed); Forms 10.1.2
  example path updated to the 12.2 dual-fs reality. No new DB tables. (4 version files bumped.)

- 2026-07-03: FEAT — Web-tier (OHS) OPatch inventory + WLS BSU report, agentless (proxy 3.21.12 → 3.21.13).
  oracle-proxy.py /api/ebs-ctrl gains two read-only ops in _CTRL_ALLOWED: fmw_opatch_web_tier (probes
  $FMW_HOME/webtier, falls back to $FMW_HOME/Oracle_WT, runs `opatch lsinventory`; EBS 12.2 OHS home) and
  bsu_list_patches (WLS 10.3.6-era Smart Update — $MW_HOME/utils/bsu/bsu.sh -report, falling back to
  $FMW_HOME; inventory display only, no currency baseline for 10.3.x). Both app-tier only, source
  EBSapps.env, 60s timeout. routes/patches.js: fmw-inventory now also runs fmw_opatch_web_tier →
  results.web_tier; NEW POST /api/patches/fmw-run-op {connection_id, op, apps_target_id?} runs a single
  inventory op via the app-tier agent (getAppTierAgentConn) with SSH fallback — powers the per-card Run
  buttons. services/ssh-executor.js: new SSH fallback keys fmw.opatch.web_tier + wls.bsu.report. public/
  ebs-patches.html: webtier card renders results.web_tier (count badge + patch list); webtier "Run" +
  WLS "Run (BSU)" buttons now use the agent path via new runAgentOp(); relabelled the standalone SSH
  buttons (WLS OPatch, Forms 10.1.2, ATG, ETCC DB/MT) "Run via SSH" → "Run" for consistency (AD/TXK left
  as-is). WLS-tab OPatch button kept on SSH — it targets $FMW_HOME/wlserver, a different home than the
  fmw_opatch_wls_home op ($FMW_HOME/Oracle_Home). No new DB tables. (4 version files bumped:
  config/proxy-version.js, oracle-proxy.py, CLAUDE.md, PROXY_SCHEMA.md.)

- 2026-07-03: FEAT — Agentless FMW OPatch inventory + WLS CPU currency (proxy 3.21.11 → 3.21.12).
  oracle-proxy.py /api/ebs-ctrl gains two read-only ops in _CTRL_ALLOWED — fmw_opatch_oracle_common,
  fmw_opatch_wls_home — that source EBSapps.env and run `opatch lsinventory` against
  $FMW_HOME/{oracle_common,Oracle_Home} as the apps user (app-tier only; timeout 60s). routes/patches.js
  POST /api/patches/fmw-inventory now PREFERS the app-tier agent (agent-channel → /api/ebs-ctrl via new
  db/patches.js getAppTierAgentConn — connection itself if server_type=apps/both proxy, else the paired
  app conn by ebs_instance_name) and falls back to the SSH path (apps_target_id) when no agent is online;
  response gains transport:'agent'|'ssh'. NEW: analyzeWlsCurrency() parses the WLS home version from the
  opatch output, looks up weblogic[version] in data/oracle-patches.json, and computes a CPU quarter gap
  (reusing computeQuartersGap) → "Missing WLS CPU patches" finding (severity + CVEs), scoped to the WLS
  core home only (oracle_common/OHS have no baseline). public/ebs-patches.html: "requires SSH" copy →
  "runs via app-tier agent"; FMW-home "Run via SSH" buttons → "Run"; removed the false BSU/version-detect
  auto-detect sentence; button enabled for any EBS conn; renders transport note + WLS currency finding.
  No new DB tables. (4 version files bumped: config/proxy-version.js, oracle-proxy.py, CLAUDE.md, PROXY_SCHEMA.md.)

- 2026-07-03: FIX — server-type validation now fires reliably + wizard client/EBS-password persistence.
  (1) The prior check (cbb0159) never fired: install.sh applied the TUNEVAULT_SERVER_TYPE override to
  SERVER_TYPE BEFORE reporting it, so the wizard command (which carries the intended type) always made the
  reported type match the stored one. Now install.sh snapshots the RAW auto-detected type
  (DETECTED_SERVER_TYPE) before the override and sends it at /confirm as detected_server_type;
  routes/agent.js confirm compares expected (stored server_type, or the intended type from the command when
  the draft PATCH hasn't landed) vs detected_server_type (real hardware) and rejects hard db↔apps conflicts.
  No longer depends on the racy draft-config PATCH; backward-compatible (skips when no detected type sent).
  Debug logging added to provision + confirm. (2) Client (client_name/client_slug) is now written at DRAFT
  CREATION (wizard Step 1 → POST /api/connections/draft) instead of only in the skippable Step-3 credentials
  save, so it persists for app servers and skipped flows. (3) Verified EBS passwords encrypt→store to
  apps_pwd_enc/weblogic_pwd_enc via the draft-config PATCH (added logging). db/agent.js
  setConnectionServerConfig now also handles clientName/clientSlug.

- 2026-07-03: FEAT — Agent server-type validation + EBS passwords moved into the Add Connection wizard.
  (1) install.sh now sends server_type to POST /api/agent/provision and captures HTTP status+body so it
  PRINTS the server's error (was swallowed as a generic "Cannot reach"). routes/agent.js validates
  server type at BOTH provision (TUNEVAULT_SERVER_TYPE override, checked via new non-consuming
  db/agent.js peekRegToken() BEFORE redeeming so a mismatch never burns the token) and confirm (the
  agent's DETECTED server_type vs the connection's expected type, before overwrite). serverTypesConflict()
  only rejects a hard db↔apps conflict — 'both'/null/unknown never conflict, so non-wizard installs are
  unaffected. On mismatch: 400 "This install token is configured for [expected] server — you appear to be
  running on a [actual] server. Use the correct install command from the Edit Connection page." install.sh
  confirm aborts fatally ONLY on this mismatch; all other confirm outcomes stay non-fatal.
  (2) connections-new.html: APPS + WebLogic password fields moved to Step 2 (before install, shown for
  apps/both, optional "required for EBS health checks"); debounced saveDraftConfig() PATCHes the selected
  server_type + encrypted passwords to the new PATCH /api/connections/:id/draft-config (routes/ssh-install.js)
  → apps_pwd_enc/weblogic_pwd_enc + server_type on the draft. Removed the old Step-3 duplicate. New
  db/agent.js helpers: peekRegToken, setConnectionServerConfig; getConnectionById now selects server_type.
  No new DB tables.

- 2026-07-02: FEAT — Standalone spy/007-themed DBA Agent at /agent (routes/dba-agent.js + public/agent.html),
  a separate feature from TuneBot. Professional+ gated (internal tier team+, admin bypass, fails CLOSED so a
  quota-check error never hands out the paid feature). GET /api/agent/context returns clearance + connection
  roster (drives the target dropdown + CLEARANCE DENIED screen). POST /api/agent/chat: same Oracle-docs RAG
  retrieval as TuneBot, then an autonomous read-only tool loop (claude-sonnet-4-6, max 5 iters, try/catch per
  tool) over 5 tools — run_sql (blockNonSelect-gated), get_active_sessions, get_top_sql (via the SQL Console's
  executeSQL: proxy/direct-TCP/SSH), get_health_check + get_addm_findings (Postgres). New two-event SSE:
  data:{"thinking":"…"} intel lines before each tool + data:{"token":"…"} answer deltas + {done}. UI is a
  near-black grid mission terminal (electric-green #00ff88, amber #ffaa00 intel, red #ff4444 classified),
  monospace, radar pulse, [ANALYST]/[AGENT-007] bubbles, DEPLOY AGENT, suggested missions. console.js now
  exports executeSQL/getConnParams/blockNonSelect. Nav: "🕵️ DBA Agent" added to the Servers dropdown. The
  earlier in-TuneBot agent was retired — TuneBot /chat is back to single-shot RAG chat. No new DB tables.
  (Bundles the earlier iterations: 6cc4e67 intel-steps-above-answer + radar-stop-on-done, 4d768b3 retire the
  in-TuneBot agent, ad25ef2/ce49ba9 Oracle-docs RAG ingestion + wiring — folded into this single feature.)

- 2026-07-02: DOCS — download.html + docs.html: add "What you need" server-architecture prerequisites,
  prominently before the install command. States that TuneVault self-hosted runs on its own dedicated
  Linux server (OL8 recommended / RHEL 8–9; 2 GB+ RAM, 10 GB+ disk), SEPARATE from Oracle DB/EBS
  servers (avoids port conflicts, resource contention, PostgreSQL-vs-Oracle clashes); the oracle-proxy
  agent installs on each Oracle/EBS server and dials OUTBOUND over HTTPS to TuneVault (binds
  localhost-only, no inbound ports); Direct-TCP alternative connects to the DB listener on 1521.
  download.html reuses the .panel/.prereq styles; docs.html adds an .info-box at the top of
  #prerequisites. Corrected an inaccurate "TuneVault needs outbound to Oracle on 8443" framing from the
  task brief — the agent binds 127.0.0.1:3100 and polls out (POST /api/agent/poll); 8443 is a WebLogic
  port, never the agent port.

- 2026-07-01: RELEASE — Self-hosted v1.2.0 (from v1.1.0). Adds update.sh (in-place updater: auto-detects
  Docker vs direct Node.js, backs up .env, git-pull-or-zip-extract, rebuild/restart, never wipes the DB,
  prints the new VERSION + agent auto-upgrade note) and a root VERSION file (1.2.0) as the release
  source-of-truth. download.html + README download links → v1.2.0. Changes since v1.1.0 (7dd3010):
    - 9859d9d: Security Posture "Default Passwords" remediation is EBS-aware — APPLSYS/APPS/APPLSYSPUB
      get FNDCPASS / AFPASSWD guidance instead of ALTER USER (a raw ALTER USER desyncs FND_USER and
      breaks app logins); genuine non-EBS accounts still get ALTER USER.
    - 639cac1: oracle-proxy.py 3.21.11 — SQL validator no longer false-blocks EXECUTE appearing as data
      ('EXECUTE ANY PROCEDURE', privilege='EXECUTE') in read-only SELECTs; Security Posture's Priv
      Escalation queries pass again. BEGIN/DECLARE/EXECUTE IMMEDIATE/EXEC-at-start still blocked.
    - d7fa5f8: Clients/Servers UX — Add Server "MSP Client Name" is now a typeahead over existing
      clients (attaches to the exact slug instead of forking near-duplicates); the client-grouped view
      gained a Delete button at parity with the flat Servers list.
    - 7c5f4d9: New docs/blog article — EBS 12.2 + 19c RAC clone (RMAN Duplicate) runbook.

- 2026-06-30: FEAT — Clients/Servers UX: existing-client autocomplete + Delete parity in client view.
  ISSUE A — Add Server wizard's "MSP Client Name" was free-text; the slug was derived purely from the
  typed text (frontend slugify) with no lookup, so near-duplicate names ("Klabs" vs "KiranAle Labs")
  forked separate client records. connections-new.html now renders a typeahead over GET
  /api/clients/clients: selecting a suggestion sets the hidden client-slug-field to that client's exact
  slug (true attach); an exact name match also attaches; only a new name via "+ Create new client"
  creates one. window._resolveClientSlug() resolves the slug in saveCredentials(); the ?client= pre-fill
  path also seeds the hidden slug so it attaches. (Fuzzy near-match "did you mean…?" warning explicitly
  deferred as future work.)
  ISSUE B — the client-grouped view (clients-detail.html) had Open Dashboard + Health Check but no
  Delete, unlike the flat Servers list (connections.html). Added a Delete button per server row that
  reuses the existing DELETE /api/connections/:id endpoint + a ported confirm modal; on success it
  reloads (and if it was the client's last connection, the page fetch 404s → existing redirect to
  /clients). Server name passed via an esc()'d data-name attribute read with getAttribute (no
  onclick-string injection). Frontend only — no schema or proxy change. Lower-risk parity chosen over
  consolidating the two views pre-launch.

- 2026-06-30: FIX — oracle-proxy.py 3.21.11: SQL validator stops false-blocking EXECUTE-as-data.
  _SQL_SAFE_DISALLOWED_WORDS_RE (the shared word blocklist gating POST /api/run_sql and sql_safe exec
  args) used \b(EXECUTE|EXEC|BEGIN|DECLARE)\b, which matched the literal word EXECUTE when it appears
  as a data value inside an otherwise legitimate read-only SELECT — e.g. the privilege name
  'EXECUTE ANY PROCEDURE' or a filter privilege='EXECUTE'. That made Security Posture's Priv Escalation
  check group fail with "SQL validation failed: disallowed content". Regex narrowed to genuine
  constructs: BEGIN/DECLARE anywhere (unchanged), EXECUTE\s+IMMEDIATE (dynamic SQL), and
  ^\s*(EXECUTE|EXEC) (a statement that STARTS with a procedure call). EXECUTE/EXEC as a mid-statement
  token is no longer blocked. Unit-tested against the two real Priv-Escalation queries (now pass) and
  BEGIN/DECLARE/EXEC-at-start/EXECUTE IMMEDIATE (still blocked). config/proxy-version.js
  LATEST_PROXY_VERSION → 3.21.11.

- 2026-06-30: FIX — Security Posture "Default Passwords" remediation: EBS-managed accounts use FNDCPASS.
  The sp_default_passwords finding recommended ALTER USER ... IDENTIFIED BY against every account in
  DBA_USERS_WITH_DEFPWD — wrong for EBS-managed schemas (APPLSYS, APPS, APPLSYSPUB), whose passwords
  must stay in sync with FND_USER / internal EBS tables; a raw ALTER USER desyncs them and breaks
  application logins even after the DB password changes. routes/security-posture.js checkDefaultPasswords()
  now classifies flagged accounts: EBS foundation (APPLSYS/APPS/APPS_NE — changed together via
  "FNDCPASS apps/<apps> 0 Y system/<system> SYSTEM APPLSYS <new>" or "AFPASSWD -c apps@<TWO_TASK> -s
  APPLSYS"), EBS gateway (APPLSYSPUB — GWYUID/AutoConfig+DBC procedure, MOS Note 1674063.1), and
  non-EBS (ALTER USER, unchanged — still correct). When EBS is detected, adds a note that product
  schemas (GL/AR/AP/INV/…) use FNDCPASS ORACLE <schema> or ALLORACLE. ACCOUNT LOCK guidance kept (it
  doesn't change the password, so no FND desync). isEbs threaded into the check. Other findings'
  ALTER USER usage reviewed: only ACCOUNT LOCK on passwordless schema-only accounts + ALTER PROFILE/
  ALTER SYSTEM config remain — none touch EBS-managed account passwords.

- 2026-06-30: FIX — oracle-proxy.py 3.21.10: extend 'unconfigured' tier to NO_APPS_PWD sibling checks.
  Follow-up to 3.21.9 (which covered admin_server + managed_servers only). The remaining
  credential-gated app-tier checks — cm_status, wf_mailer, adop_status, invalid_objects, ts_usage —
  routed their NO_APPS_PWD (missing APPS password) sentinel to _ok (green pass with a hint). They now
  route to _unconfigured() like the WebLogic checks, so a connection with no APPS password shows all
  of them as the amber/gray ⚙ "Not Configured" tier instead of green — excluded from score and the
  "Checks Passed" count. Copy: "Not configured — set APPS password in Edit Connection." No report.html
  change needed (the row/metric-card rendering added in 3.21.9 is generic over check_id).
  config/proxy-version.js LATEST_PROXY_VERSION → 3.21.10.

- 2026-06-30: FIX — oracle-proxy.py 3.21.9: EBS app-tier health check 'unconfigured' tier.
  /api/ebs-app-healthcheck previously reported the WebLogic Admin Server (NO_PASSWORDS) and Managed
  Servers (NO_WLS_PWD) checks as a green pass (_ok) when WebLogic/APPS passwords were not set on the
  connection — a false positive: the checks never ran. Added a third check classification alongside
  _ok / _finding: _unconfigured() → checks_unconfigured[]. Both sentinels now route there. Excluded
  from the score (the score loop only deducts for findings) and from "Checks Passed"
  (checks_total = findings + checks_ok). public/report.html: new amber/gray ⚙ "Not Configured" row
  tier (sevColor/sevIcon/sevBg extended for 'unconfigured'; checkMap gains isUnconfigured) + its own
  "Not Configured" metric card; copy "Not configured — set WebLogic/APPS password in Edit Connection".
  server.js fetchMetricsFromProxy passes checks_unconfigured through the apps metrics object. NO_APPS_PWD
  siblings (cm_status/wf_mailer/adop_status/invalid_objects/ts_usage) intentionally left as _ok (out of
  scope). Node Manager (adnodemgrctl) unaffected — it has no credential skip-to-pass path and already
  reports critical when not running. config/proxy-version.js LATEST_PROXY_VERSION → 3.21.9.

- 2026-06-30: FEAT — site-wide self-hosted origin rewrite via nav-component.js.
  Moved the docs.html origin-rewrite into nav-component.js (loaded by every page) so all pages get
  it: on a non-*.tunevault.app host, hardcoded https://tunevault.app URLs in code/pre/.tv-origin-cmd
  blocks + [data-copy] copy buttons are rewritten to location.origin, and .tv-origin-host labels to
  location.host. Leaf-only (children.length===0) so highlighted markup isn't flattened; bare-host
  rewrite is opt-in (.tv-origin-host) to avoid touching emails/prose. No-op on cloud. Runs on
  DOMContentLoaded (nav-component loads at body-bottom — fine for rendered content). docs.html inline
  rewrite removed (now redundant); its .tv-origin-* markers + head font-loader kept.
  NOT done: the font-loader could NOT move to nav-component.js — it loads body-bottom, after the
  head's render-blocking Google-Fonts <link> has already fetched, and removing it late would flash
  fonts on internet-connected self-hosted boxes. Fonts remain per-page (docs.html has the conditional
  loader; other pages keep the static link + graceful system-font fallback). True site-wide font fix
  needs either per-page head edits or committed woff2 binaries — deferred.

- 2026-06-30: FIX — docs.html: air-gap-safe fonts + origin-aware install/verify commands.
  (a) Google Fonts <link> replaced with a head script that loads it ONLY on *.tunevault.app
  (cloud); self-hosted/air-gapped instances make no external font request and use the existing
  system-font fallbacks (Space Grotesk → system sans, JetBrains Mono → system mono). NOTE: this
  isn't true font self-hosting (would need the woff2 binaries committed) — it eliminates the
  external fetch without degrading cloud typography. (b) install + verify commands and the bare
  hostname labels (arch node, firewall table) now rewrite hardcoded tunevault.app to the serving
  instance's origin/host on self-hosted (.tv-origin-cmd → https URLs, .tv-origin-host → bare host,
  plus [data-copy] copy buttons); on *.tunevault.app they stay tunevault.app. Guarded by hostname so
  cloud docs are unchanged. (docs.tunevault.app host-route serves docs.html for all paths, so
  rewriting to that origin would break — hence the guard.)

- 2026-06-30: FIX — download.html docs links → relative /docs (self-hosted air-gap friendly).
  Both "Read the Docs" links pointed at https://docs.tunevault.app, which is unreachable on an
  isolated self-hosted network and confusingly bounces a self-hosted user to the cloud SaaS domain.
  Changed to /docs. Confirmed no content gap: server.js serves the SAME public/docs.html for both
  the docs.tunevault.app host-route (req.hostname check) and the /docs route, and docs.html ships in
  the self-hosted zip — so /docs resolves to identical content on whichever instance serves the page.

- 2026-06-30: FEAT — TuneBot frontend: tailored message when AI chat isn't configured.
  public/tunebot.js askAI(): on the /chat 503 (no ANTHROPIC_API_KEY), instead of a generic
  "Error 503", show "TuneBot's AI chat isn't configured on this TuneVault instance (no
  ANTHROPIC_API_KEY)…" and fall back to the built-in knowledge base answer for the question
  (renderMarkdown). Status pill → "AI not configured" (amber), concerned expression. Completes
  the self-hosted AI-optional UX.

- 2026-06-30: FEAT — AI keys optional: clean degradation when OPENAI_API_KEY / ANTHROPIC_API_KEY absent.
  server.js: AI_ENABLED = !!OPENAI_API_KEY; one-time startup log when unset. runAIAnalysis() returns
  early with a clear placeholder (ai_analysis/summary_text/top_action, ai_recommendations=[],
  analysis_stage='completed_no_ai') instead of calling OpenAI — the 200+ check_results still persist,
  so all monitoring data shows. generateExecutiveSummary/generateStructuredRecommendations get
  defensive `if (!AI_ENABLED) return`. routes/tunebot.js: /chat returns 503
  {"error":"TuneBot is not configured — ANTHROPIC_API_KEY is not set"} before opening the SSE stream.
  Key-present behavior unchanged. Docs aligned so claims match: .env.example AI keys blanked + marked
  OPTIONAL (a non-empty placeholder would have been truthy → auth errors); setup.sh first-run message,
  download.html ("Required — license key"; OpenAI/Anthropic optional), and README.md configure step
  all now say AI keys are optional and what's disabled without them.

- 2026-06-30: FIX — oracle-proxy.py 3.21.8: cloud DNS-resolve milestone no longer fatal on a
  transient blip. Milestone 4 (main()): extract the bare host via urlsplit().hostname (strips
  scheme/port/IPv6 brackets); if it's a literal IP (ipaddress.ip_address) — the common self-hosted/
  LAN case — log "literal IP — skipping DNS resolve" and go straight to the reachability probe.
  For hostnames, retry gethostbyname() 3× with 2s delay before the existing sys.exit(6), so a single
  transient DNS failure right after VM boot/network-settling doesn't kill the agent. Fail-fast
  philosophy unchanged. VERSION + config/proxy-version.js LATEST_PROXY_VERSION → 3.21.8 (agents
  auto-upgrade). Confirmed: oracle-proxy.py:483 TUNEVAULT_URL fallback to tunevault.app only fires
  when TUNEVAULT_API_URL/TUNEVAULT_URL are both unset; oracle-proxy.py:361 repair-command print is a
  static help string (no connection), not gated but harmless — neither silently misdirects self-hosted.

- 2026-06-30: FEAT — setup.sh: bake in the manual OLAM fixes + color-coded UX.
  Part 1 (real logic): (a) pg_hba.conf local-auth fix — locate the file via `find` (not
  `psql SHOW hba_file`, which needs working auth), and if `local all all` is md5/password/
  scram-sha-256, rewrite to peer + restart PG, BEFORE any `sudo su - postgres psql`; also force
  localhost TCP (127.0.0.1/::1) host lines to md5. Surgical (replication/comment lines untouched),
  idempotent. (b) SELinux: relabel .env as etc_t (semanage fcontext + restorecon) when getenforce=
  Enforcing — after the SELF_HOSTED step AND again after the DATABASE_URL rewrite, before the
  service starts. Part 2 (UX, no logic change): color helpers ok/warn/fail/step, phase headers,
  ERR trap reporting the failed phase, green success summary using APP_URL + magic-link next steps,
  auto-fixes wrapped in warn(). Failed service start now exits 1. Validated: bash -n + a harness
  running the exact grep/sed against sample pg_hba.conf (md5/ident/peer/scram, replication, comments,
  idempotency) and a mocked getenforce/semanage/restorecon flow (16/16 assertions).

- 2026-06-30: FIX — db/baseline-schema.sql replaced with the real pg_dump from production Neon.
  The hand-written reconstruction was missing real columns (users.company_domain/google_id/team_id/
  sso_provider/manager_role), which broke magic-link login on self-hosted ("column company_domain
  of relation users does not exist"). Replaced byte-for-byte with `pg_dump --schema-only` of prod
  (98 tables, 7863 lines, verified to include the missing columns). The file is kept exactly as the
  dump; migrate.js now applies a raw pg_dump correctly: (1) strips psql meta-commands (\restrict /
  \unrestrict, pg_dump 16+) the JS driver can't run, (2) the dump includes _migrations so the
  tracking-table CREATE moved to AFTER runBaselineSchema, (3) the dump sets search_path='' so we
  SET search_path TO public afterward for the unqualified core/delta migrations.

- 2026-06-30: FIX — self-hosted CSP: upgradeInsecureRequests must be null, not omitted.
  middleware/security.js: helmet 8 MERGES our CSP directives with its built-in defaults, so
  omitting upgradeInsecureRequests fell back to helmet's default `[]` and kept forcing HTTPS
  upgrades on self-hosted. Now explicitly `...(isSelfHosted ? { upgradeInsecureRequests: null } :
  { upgradeInsecureRequests: [] })` — null is the only value helmet treats as "exclude this
  directive." Completes the prior self-hosted HTTPS-gating fix.

- 2026-06-30: FIX — self-hosted: don't force HTTPS (HSTS / upgrade-insecure-requests).
  middleware/security.js: added `const isSelfHosted = process.env.TUNEVAULT_SELF_HOSTED === 'true'`;
  CSP `upgradeInsecureRequests: []` now spread-gated (`...(isSelfHosted ? {} : { … })`) and
  `strictTransportSecurity` gated to `false` on self-hosted — so plain-HTTP LAN installs aren't
  upgraded/forced to HTTPS. (HSTS was NOT previously gated despite a note saying so — applied it.)
  .env.example: added `TUNEVAULT_SELF_HOSTED=true`. setup.sh: appends the flag to any pre-existing
  .env that lacks it, so self-hosted mode is never something the operator has to set by hand.

- 2026-06-30: FEAT — db/baseline-schema.sql: hand-written full schema for fresh self-hosted installs.
  Instead of a pg_dump (no Neon access), the baseline was reconstructed from CLAUDE.md's Database
  section + every db/*.js module's INSERT/SELECT/ON CONFLICT + migrate.js + server.js ensureColumns().
  96 tables (all 87 documented + ebs_jobs + 8 app tables: magic_link_tokens, install_jobs,
  job_log_lines, listener_preflight_runs, installer_smoke_tokens/runs, demo_runs, user_hc_usage),
  all CREATE TABLE IF NOT EXISTS; 27 unique + 93 plain CREATE INDEX IF NOT EXISTS (every ON CONFLICT
  target has a matching unique index, incl. idx_tuneops_tickets_number). No FKs/extensions/grants —
  fully idempotent, applies as a non-superuser. migrate.js runBaselineSchema() applies it on fresh DBs
  (health_checks sentinel). Validated structurally (balanced parens, no trailing/double commas, all
  88 required tables present); not yet apply-tested against a live PG (no local Postgres/Docker here).

- 2026-06-29: FIX — fresh-install schema: baseline migration replaces the (ineffective) bootstrap.
  Discovery: server.js ensureColumns() does NOT create the base schema — it only creates ebs_jobs
  and ALTERs/​backfills tables that must already exist; the ~80 app tables (health_checks,
  check_results, payments, tuneops_tickets, compliance_reports, …) have no CREATE TABLE DDL anywhere
  in the repo (they live only in the production Neon DB). So the prior "boot server.js to create the
  schema" step created nothing on a fresh DB. Replaced it: migrate.js now applies a baseline schema
  (db/baseline-schema.sql, a pg_dump --schema-only of a known-good DB) ONCE on a fresh database —
  gated by a health_checks sentinel so it's a no-op on existing/prod DBs — before the core + delta
  migrations. setup.sh drops the bootstrap and just runs `node migrate.js`. NOTE: db/baseline-schema.sql
  must be generated and committed for fresh installs to work (command in migrate.js docstring); until
  then migrate.js logs a clear warning on a fresh DB.

- 2026-06-29: FIX — fresh self-hosted installs: bootstrap base schema before delta migrations.
  The migrations/ folder holds delta migrations that ALTER/INSERT into base tables
  (oracle_connections, blog_posts, compliance_reports, tuneops_tickets, …) which only server.js
  ensureColumns() creates — so `node migrate.js` failed on a brand-new DB. setup.sh now boots
  server.js briefly (timeout 30 node server.js & → sleep 15 → kill) before migrate.js so
  ensureColumns() creates the ~80 base tables first; kill/wait guarded with `|| true` so the
  killed-process exit code doesn't trip `set -e`. migrate.js runCoreMigrations() also creates
  oracle_connections (CREATE TABLE IF NOT EXISTS) so the most-referenced table exists even without
  the bootstrap; ensureColumns() still adds any missing columns on normal startup.

- 2026-06-29: FEAT — setup.sh hardening: env sourcing, no-prompt PG setup, OL7 guard, self-check.
  (1) Sources .env (set -a; source .env; set +a) after SESSION_SECRET so DATABASE_URL/DB_PASSWORD
  reach node migrate.js + server.js without manual exports — reloaded again after DATABASE_URL is
  written. (2) Role/DB creation now uses peer auth via `su - postgres` with existence checks (no
  password prompts; idempotent). (3) pg_hba.conf auto-fixed: finds the file via `SHOW hba_file`,
  sets localhost 127.0.0.1/::1 host auth to md5 (accepts SCRAM-stored passwords on PG10+), reloads.
  (4) DATABASE_URL written-or-updated in .env, then env reloaded. (5) Oracle Linux 7 now stops with
  a clear error (glibc 2.17 < Node 18's 2.25 requirement) pointing to Docker Compose or an OL8/9
  host — direct install supports OL/RHEL 8/9 only. (6) systemd unit uses EnvironmentFile=.env,
  User=root, ExecStart=$(which node), journal logging. (7) Final status check: `systemctl is-active`
  → prints browser URL + next steps, or the failing journalctl on error.
  Adaptations vs. the raw snippets: psql calls use $PSQL (PGDG's psql is off-PATH) and reloads use
  $PG_SERVICE; privileged commands keep `sudo` so `bash setup.sh` works for a sudo user (not only root).

- 2026-06-29: FIX — self-hosted release zip: force Unix LF line endings.
  Root cause: the committed blobs are LF, but `git archive` rewrites every text file to CRLF when
  run on a machine with core.autocrlf=true (e.g. a Windows build box) — which would ship a broken
  setup.sh / install.sh / oracle-proxy.py (CRLF shebangs fail on Linux). Releases built on Linux/CI
  (autocrlf=false) were already fine. Fix: scripts/build-selfhosted-release.sh now runs
  `git -c core.autocrlf=false archive …` so all files are emitted verbatim (LF) regardless of build
  OS. Added a belt-and-suspenders post-zip pass that strips CR from *.sh inside the zip (uses
  zip+unzip, falls back to a real python3, else skips — never fatal). .gitattributes gains
  `*.sh text eol=lf` so shell-script blobs stay LF. Verified: 0 CR bytes in setup.sh/install.sh/
  uninstall.sh/oracle-proxy.py inside the built zip.

- 2026-06-29: FIX — /download nav now initializes + renders correctly.
  public/download.html: added `tvNav.init('download')` after nav-component.js (the page had the
  <nav> + script but never called init, so the nav didn't render). public/nav-component.js:
  added `download:1` to AUTH_TYPES so logged-in visitors get the full app nav on /download
  (same treatment as pricing/docs/vault); non-logged-in visitors get the public nav.

- 2026-06-29: FEAT — setup.sh: post-install DB connection smoke test (direct-install path).
  Before running migrations, the script now verifies the app's localhost TCP password connection:
  `PGPASSWORD=$DB_PASS $PSQL -h localhost -U tunevault -d tunevault -w -tAc 'SELECT 1'`. On failure
  it prints the likely causes (pg_hba rejecting password auth on 127.0.0.1, DB_PASSWORD mismatch,
  PG not listening on :5432) and exits 1 — so a bad pg_hba/auth setup fails loudly here instead of
  surfacing as a confusing error inside node migrate.js. Uses $PSQL so it works on both the PGDG
  (/usr/pgsql-15/bin) and distro/apt paths; -w prevents a hang on a password prompt.

- 2026-06-29: FEAT — setup.sh: PGDG PostgreSQL 15 auto-install for OL/RHEL 7/8/9.
  Direct-install path now installs PostgreSQL 15 from the official PGDG repo instead of the distro
  default (9.2 on OL7). Steps: install pgdg-redhat-repo RPM for EL${MAJOR}-$(uname -m); `dnf module
  disable postgresql` on EL8+ (gated on dnf presence); install postgresql15-server + client;
  `/usr/pgsql-15/bin/postgresql-15-setup initdb`; enable/start postgresql-15. Because PGDG binaries
  live in /usr/pgsql-15/bin (off PATH) and the service is postgresql-15, the script now uses a $PSQL
  var (full path) for CREATE USER/DATABASE, prepends the bin dir to PATH, and templates the systemd
  unit's After= with $PG_SERVICE. pg_hba.conf 127.0.0.1/::1 host lines are forced to scram-sha-256
  (+reload) so the app's localhost TCP password connection works. Debian/Ubuntu still uses distro PG.
  README Option A updated.

- 2026-06-29: FEAT — setup.sh OS / Oracle Linux version guard (direct-install path).
  Reads /etc/os-release for distro + major version, prints detected OS. RHEL family (ol/rhel/
  centos/rocky/almalinux/fedora): blocks major < 7 with a clear "use Docker or upgrade" message,
  notes when newer than 9; non-supported distros get a best-effort warning. PostgreSQL initdb is
  now version-aware (OL/RHEL 7 → `postgresql-setup initdb`, 8+ → `--initdb`, with cross-fallback),
  and OL/RHEL 7 prints a note that its default PostgreSQL 9.2 may need the PGDG repo or Docker.
  README.md Option A updated with the supported-distro list.

- 2026-06-29: FEAT — Self-hosted setup.sh supports direct Node.js install (not just Docker).
  setup.sh: after writing .env + auto-generating SESSION_SECRET, tries Docker Compose first; if
  docker/docker-compose are absent, falls back to a direct install — installs Node.js 20 +
  PostgreSQL via yum/apt when missing, creates the tunevault DB+user, rewrites DATABASE_URL to
  localhost, npm install --production, runs migrate.js, and registers a tunevault.service systemd
  unit. Prints service/URL/journalctl hints. public/download.html: Prerequisites panel → "Install
  options" with Option A (Direct, Node.js 18+, no Docker, recommended for Oracle Linux) + Option B
  (Docker Compose); step 1 now curls the R2 zip; step 3 notes auto-detect. README.md: new
  Self-Hosted Installation section documenting both paths.

- 2026-06-29: FIX — Self-hosted download button → R2 release URL.
  public/download.html: "Download Latest Release" button href changed from the GitHub releases
  page to https://releases.tunevault.app/tunevault-selfhosted-v1.0.0.zip (R2-hosted zip). The
  Step-1 `git clone` URL is left as-is (repo clone, not a release artifact). index.html
  "Self-Hosted →" button already points to /download — verified, no change.

- 2026-06-29: FEAT — TuneVault self-hosted package.
  New root files: docker-compose.yml (app via `build: .` + bundled postgres:15-alpine with
  healthcheck/depends_on), .env.example (config template; .gitignore gains `!.env.example`
  exception so it's tracked while real .env stays ignored), setup.sh (one-command: copies env,
  auto-generates SESSION_SECRET via openssl, docker-compose up -d --build).
  routes/license.js: GET /api/license/status → {mode:'cloud'|'self-hosted', valid, ...}; honor
  system (accepts any TV-* key; real license server TBD). Mounted at /api/license in server.js.
  public/download.html: dark-themed /download page (Download → Configure → Run, prerequisites,
  GitHub-releases placeholder link, docs link); route added in server.js.
  index.html bottom CTA gains a "Self-Hosted →" outline button; nav-component.js Resources
  dropdown gains "⬇️ Self-Hosted" → /download. CLAUDE.md: new Self-hosted package section.
  Compose sets PORT=3000 (overrides Dockerfile ENV PORT=10000) so container listens on 3000.

- 2026-06-29: FIX — TuneOps: remove breadcrumb, fix sort, hide conn icon when no connection.
  public/tuneops.html: (1) removed the hero breadcrumb links (← Dashboard / DB Ops / Vault) +
  dead .hero-actions CSS. (2) sort now orders by status first (OPEN/REOPENED → ACKNOWLEDGED →
  EXECUTING → DEFERRED → RESOLVED) then severity (critical → warning → info), fixing warnings
  appearing above criticals within the same status. (3) connection icon is gated on
  t.connection_id (📱 apps / 🗄️ otherwise); tickets with no connection show just "—" (no ⚙️).

- 2026-06-29: FIX — TuneOps header columns misaligned with rows after the redesign.
  public/tuneops.html: the severity left-border (3px) + padding-left (10px) added to each
  ticket-row shifted its grid 13px right of the .ticket-thead. Bumped .ticket-thead left
  padding 16px → 29px (16 + 3 + 10) so both grids start at the same x and the columns line up.

- 2026-06-29: FEAT — TuneOps page visual redesign.
  public/tuneops.html: (1) stat cards gain colored left borders (Open/Critical=red, Warning=amber,
  Resolved=green, Avg Resolution=blue) + Open card shows "N critical · M warning" breakdown.
  (2) ticket rows get a severity left-border (critical=red, warning=amber, else transparent).
  (3) age is color-coded (<4h green, <24h amber, ≥24h red) via ageColor(). (4) connection column
  gets a server-type icon (📱 apps / 🗄️ db / ⚙️ other). (5) detail panel adds a status Timeline
  (OPEN → ACKNOWLEDGED → EXECUTING → RESOLVED pills). (6) tickets sorted OPEN-first then by severity.
  (7) .main-wrap/.ticket-list bottom padding so content clears the TuneBot FAB. (8) header: title →
  "TuneOps", eyebrow "DBA Ticketing", subtitle "Auto-created from health checks. Acknowledge →
  Execute → Resolve.", nav-style breadcrumb hover.
  Backend support: db/tuneops-tickets.js listTickets selects oc.server_type (for the icon);
  acknowledgeTicket now sets acknowledged_at; migration 1782710000000 adds the acknowledged_at
  column (additive, ADD COLUMN IF NOT EXISTS) so the Timeline's ACKNOWLEDGED pill is accurate.

- 2026-06-29: FEAT — TuneOps notification taxonomy rename: 'acknowledged' event → 'deferred'.
  Completes the status rename through the notification layer (the prior entry had left this
  as a flagged follow-up). services/tuneops-mailer.js: notifyAcknowledged → notifyDeferred,
  param acknowledgedByName → deferredByName, eventType 'acknowledged' → 'deferred', subject/body
  copy ("deferred by X — no action taken" / "has deferred this ticket — parked…"), exports updated.
  routes/tuneops-notifications.js (admin test/preview endpoint): case 'acknowledged' → 'deferred',
  mailer.notifyDeferred call, valid eventType array updated.
  services/tuneops-ticket-engine.js: stale auto-resolve comment now says open/acknowledged/deferred.
  Safe: tuneops_notification_log.event_type is free-form (no CHECK constraint) — historical
  'acknowledged' log rows kept as-is (audit), no data migration. The event was only wired to the
  admin preview endpoint (not the live ticket flow), so no real-flow rewire. Unrelated 'acknowledged'
  values (alert_events status, agent key_rotation_status) untouched.

- 2026-06-29: FIX — TuneOps status rename: stray old-vocabulary copy on other pages.
  Follow-up sweep after the status rename. public/index.html: demo ticket audit line
  "Confirmed by" → "Acknowledged by"; .ticket-status.confirmed CSS class → .acknowledged.
  public/mcp.html: list_tuneops_tickets tool desc "Open, confirmed, and resolved" →
  "Open, acknowledged, and resolved". services/tuneops-mailer.js: assignee/approval email
  body prose "review and confirm"/"has confirmed" → "acknowledge"/"has acknowledged".
  Verified: zero CONFIRMED status literals remain project-wide (outside the migration);
  vault.html only creates tickets (no status render); api-docs.html v1 filter is open/resolved;
  admin/requests.html + settings/team.html "status" are unrelated domains (leads / approvals).
  Note: the notification taxonomy eventType 'acknowledged' / mailer.notifyAcknowledged ("accept
  risk, no action") semantically maps to the new DEFER action — left as-is (backend event
  taxonomy, not a page status; rename would touch the notification log/dedup + preview endpoint).

- 2026-06-29: FEAT — TuneOps status rename: CONFIRMED→ACKNOWLEDGED, ACKNOWLEDGED→DEFERRED.
  Underlying statuses now match the UI labels (replaces the prior display-only relabel).
  Mapping (applied as a swap — ACKNOWLEDGED is both a source and a target):
    old CONFIRMED    → ACKNOWLEDGED (active: reviewed, will execute)
    old ACKNOWLEDGED → DEFERRED     (parked: seen, not acting now)
  Data migration: migrations/1782700000000_tuneops_status_rename_confirmed_acknowledged.js —
  runs once in a transaction via migrate.js on deploy; vacates ACKNOWLEDGED→DEFERRED FIRST,
  then CONFIRMED→ACKNOWLEDGED (order avoids the collision). Not re-runnable-corrupting because
  the migrate runner tracks it in _migrations and runs it exactly once.
  routes/tuneops.js: POST /confirm → /acknowledge (db.acknowledgeTicket), POST /acknowledge →
  /defer (db.deferTicket); execute now requires ACKNOWLEDGED; resolve/reopen guards use DEFERRED.
  db/tuneops-tickets.js: confirmTicket→acknowledgeTicket (sets ACKNOWLEDGED), acknowledgeTicket→
  deferTicket (sets DEFERRED); executeTicket requires ACKNOWLEDGED; resolveTicket/findOpenByCanonicalKey/
  autoResolveByCanonicalKey/getStats status sets remapped.
  services/tuneops-ticket-engine.js: auto-resolve open-set + demo ticket statuses remapped.
  public/tuneops.html: filter data-status values, buildActions actions ('acknowledge'/'defer') +
  Execute(ACKNOWLEDGED)/Resolve/Reopen(DEFERRED) guards, status-badge CSS (.status-ACKNOWLEDGED blue,
  .status-DEFERRED grey), button classes btn-acknowledge/btn-defer; removed now-redundant statusLabels
  display map (status value == label).
  routes/mcp.js + public/mcp-docs.html + public/tunebot.js + public/index.html + routes/ai-discoverability.js:
  status enum / lifecycle copy updated to the new names.

- 2026-06-29: FEAT — TuneOps terminology relabel + required resolution notes.
  public/tuneops.html (UI labels only — no status values changed): action buttons relabeled
  Confirm→Acknowledge (action stays 'confirm'), Acknowledge→Defer (action stays 'acknowledge').
  Filter buttons: CONFIRMED→"Acknowledged", ACKNOWLEDGED→"Deferred". Status badge uses a
  statusLabels display map ({CONFIRMED:'Acknowledged', ACKNOWLEDGED:'Deferred'}) — stored status
  unchanged. Subtitle → "Findings auto-create tickets. Acknowledge, execute, resolve — with a
  full audit trail." submitResolve() now requires non-empty notes (red border + inline prompt +
  focus on empty); openResolveModal() resets border/placeholder so each open starts clean;
  "(optional)" dropped from the textarea placeholder to match the now-required field.

- 2026-06-29: FEAT — TuneOps resolve modal + Reopen action.
  public/tuneops.html: browser prompt() for resolution notes replaced with a themed modal
  (#resolve-modal + openResolveModal/closeResolveModal/submitResolve). ticketAction() resolve
  branch now opens the modal instead of prompt(). Reopen button added in buildActions() for
  RESOLVED/ACKNOWLEDGED tickets (.btn-reopen accent style); routes to ticketAction(...,'reopen').
  routes/tuneops.js: POST /tickets/:ticket_number/reopen added after acknowledge — company-scoped
  (companyIdForUser, not user_id), 409 if not RESOLVED/ACKNOWLEDGED, delegates to existing
  db.reopenTicket(id) helper (sets REOPENED, increments reopened_count, nulls resolved fields).

- 2026-06-29: FIX — TuneBot FAB viewport clamping + TuneOps detail click bubbling.
  public/tunebot.js: stored FAB position restore now clamps to [0, innerWidth-80]×[0, innerHeight-80]
  and nulls/removes the stored position if it falls outside the viewport (guards against window
  shrink / resolution change leaving the FAB off-screen). Drag-end save also clamps to the same
  bounds so a drag past the edge can't persist an off-screen anchor.
  public/tuneops.html: ticket-detail div + action-row div gain onclick="event.stopPropagation()"
  so clicking text or buttons inside an expanded ticket no longer bubbles to the row and collapses
  it. (ticketAction() already called e.stopPropagation(); the action-row guard is defense-in-depth.)

- 2026-06-29: FIX — TuneBot widget hardening + Internal Manager recommendation EBS 12.2 fix.
  public/tunebot.js: buildWidget() body wrapped in try/catch (console.error on failure) so a
  render error can't leave the page without a FAB; fetchContext() loadChatHistory() call wrapped
  in try/catch (console.warn) so a corrupt history blob can't break context load.
  server.js + routes/proxy-self-test.js: Internal Manager down recommendation "restart via FNDSM
  or adcmctl.sh start" → "restart via adcmctl.sh start apps/apps_password" (FNDSM-as-command is
  EBS 11i only; adcmctl.sh is the 12.2 path). Other FNDSM references (process/service names in
  sanity-checker.js, ebs-ops.html, oracle-proxy.js) left as-is — correct for 12.2.

- 2026-06-29: FIX — TuneOps ticket row click handler broke on special chars in ticket data.
  public/tuneops.html renderTicket(): inline onclick="toggleRow(this, {JSON})" embedded
  JSON.stringify(t) in the attribute; HTML entity encoding corrupted the JSON when ticket
  fields contained <, >, quotes, etc. Switched to data-ticket="encodeURIComponent(JSON)" +
  onclick="toggleRow(this)". toggleRow() now JSON.parse(decodeURIComponent(...)) from the
  attribute and re-renders the detail panel via buildDetail() on expand for fresh content.

- 2026-06-28: FIX — TuneBot pricing FAQ + pricing.html grain z-index.
  public/tunebot.js: pricing FAQ answer rewritten from stale 4-tier flat pricing ($99/$249/$599)
  to current 5-tier per-conn structure (Starter $49 → Managed Services $12) with conn/user ranges
  + annual discount + free tier note. public/pricing.html: .grain full-screen overlay z-index
  999 → 99 so the TuneBot FAB (z-index 99999) renders above it. (.grain already had
  pointer-events:none; .pay-loading + .pricing-modal are display:none by default so neither
  intercepts FAB clicks.)

- 2026-06-28: FEAT — Pricing page repositioning: new motto + competitor cost framing.
  public/pricing.html: hero h1 "Pay for connections. Not features." → "Enterprise Oracle monitoring.
  Without enterprise pricing." Sub-tagline replaced with "Full Oracle DB + EBS health monitoring,
  operational controls, and AI assistance — at a fraction of the cost of SolarWinds, Foglight, or OEM."
  routes/tunebot.js buildSystemPrompt(): competitor pricing line added after /pricing (SolarWinds DPA
  ~$125/instance/mo, Datadog DBM ~$70, Quest Foglight ~$150; TuneVault from $49/conn, no feature gating).

- 2026-06-28: FEAT — Pricing overhaul: 5-tier volume-based structure.
  config/pricing.js: 4-tier → 5-tier (Starter $49, Professional $35, Business $25, Enterprise $18,
  Managed Services $12). CONN_PRICES: professional (6-25 conns, 10 users), enterprise (81-300 conns,
  50 users), managed_services (301-1000 conns, unlimited users). PER_CONN_TIERS updated.
  Removed ENTERPRISE_VOLUME_THRESHOLDS + enterpriseVolumeDiscount() (volume tiers replace discounts).
  public/pricing.html: Team card → Professional; Enterprise card → direct checkout (no calculator);
  new Managed Services card ($12/conn, purple theme); enterprise calc section removed; comparison
  table gains Managed Services column + Professional header; FAQ updated (Team→Professional, annual
  savings, connection limits, enterprise FAQ, new Managed Services FAQ); PLAN_PRICES JS object →
  5 tiers; updateCards() handles prof/ent/ms; PRICING_TIER_ORDER/TIER_MIN_CONNS/PRICING_LEGACY_MAP
  updated; dead calc JS (scrollToEnterpriseCalc/updateEnterpriseCalc/startEnterpriseCheckout) removed;
  bottom structured data updated; btnClassForTier gains managed_services case.
  routes/tunebot.js: /ebs-ops line expanded with all 9 ADOP phases; ADOP key concept added to
  Key concepts section.

- 2026-06-28: FEAT — TuneBot added to 8 more pages + chat history persists across navigation.
  public/tunebot.js: fetchContext() success handler calls loadChatHistory() so history
  restores on every page. clearBtn (NEW) now removes tvBotHistory_<connId> from localStorage —
  history only wipes on explicit NEW click, not page navigation.
  <script src="/tunebot.js"> added before </body> in: mcp.html, mcp-docs.html, tune.html,
  tuneops.html, vault.html, security.html, reports.html, addm.html.

- 2026-06-28: FIX — TuneBot system prompt: agent install URL → tunevault.app (was render.com URL).

- 2026-06-28: FIX — TuneBot system prompt: pricing tiers, db-ops tabs, remove /fleet reference.
  routes/tunebot.js buildSystemPrompt(): /pricing updated to volume tiers (Starter $49/conn 1-5
  → Managed Services $12/conn 300+) with free tier note. /db-ops description replaced stale
  "32 ops / 11 categories" with tab names (Critical/Daily, Performance, Maintenance, Start/Stop).
  /fleet legacy line removed to avoid surfacing deprecated page.

- 2026-06-28: FIX — TuneBot system prompt: MSP → Clients rename + /clients description update.
  /clients description: added Fleet Overview tab note + "Replaces /fleet and /msp". Pricing:
  MSP → Clients plan names. Key concepts: "for MSP use" → "for multi-client management".

- 2026-06-28: FIX — TuneBot: duplicate id="tb-stream-box" caused all questions after first to fail.
  public/tunebot.js askAI(): stream box id changed from fixed 'tb-stream-box' to
  'tb-stream-box-' + Date.now() per call. getElementById always returned the first bubble's
  box, so subsequent tokens streamed silently into the original message instead of the new one.

- 2026-06-28: FIX — DB Ops: clear stale result immediately on re-run.
  public/db-ops.html runOp(): resultStore.delete(opKey) called first so old entry can't leak
  back if fetch is cancelled. Result panel now shows a two-part loading state (header spinner +
  "Waiting for response…" body) instead of a header-only fragment.

- 2026-06-28: FEAT — oracle-proxy.py 3.21.7 + install.sh: su oracle for sqlplus startup/shutdown.
  install.sh: ORACLE_OS_USER= added to agent.env heredoc; after PMON detection, owner of the
  ora_pmon_ process is read via `ps -ef | awk NR==1{print $1}`, defaulting to 'oracle' if root
  or empty; patched into agent.env via sed. oracle-proxy.py 3.21.7: global _ORACLE_OS_USER read
  from agent.env at startup. db_* dispatch: sqlplus ops now run as
  `su - <oracle_user> -c "ORACLE_SID=<sid> ORACLE_HOME=<oh> <oh>/bin/sqlplus -S '/ as sysdba'"`
  with SQL piped via stdin — same su+stdin pattern as EBS ctrl ops. Ensures OS auth succeeds
  via dba group membership regardless of which user the proxy agent runs as.

- 2026-06-28: FIX — install.sh + oracle-proxy.py 3.21.6: persist ORACLE_SID to agent.env.
  install.sh: ORACLE_SID= + ORACLE_SIDS= placeholders added to agent.env heredoc template.
  After PMON detection, sed-patches both values: ORACLE_SID=<first SID>, ORACLE_SIDS=<all>.
  oracle-proxy.py 3.21.6: _ORACLE_SID reader now also checks ORACLE_SIDS= and takes first
  entry as fallback (handles older installs that have ORACLE_SIDS but no ORACLE_SID key).
  Existing agents without ORACLE_SID in agent.env: manually add ORACLE_SID=<SID> to
  /etc/tunevault/agent.env and restart: systemctl restart tunevault-agent.

- 2026-06-28: FIX — oracle-proxy.py 3.21.5: ORACLE_SID not set for sqlplus / as sysdba.
  db_* dispatch block: added global _ORACLE_SID read from agent.env at startup (same pattern
  as _APPS_ENV_FILE, _CONTEXT_FILE etc). In db_* handler, sets os.environ["ORACLE_SID"] = _sid
  before run_os_command so sqlplus OS auth works. Without it: ORA-12162 TNS:net service name
  is incorrectly specified (sqlplus can't identify the instance). lsnrctl ops unaffected.

- 2026-06-28: FIX — DB Ops: agent ops (startup/shutdown/listener) blocked on proxy connections.
  routes/db-ops.js proxy gate: was checking opEntry.type !== 'sql' and rejecting all non-SQL ops,
  including the newly-added type:'agent' ops, with "SSH ops require a direct connection". Fix:
  type:'agent' ops now skip the proxy SQL path and fall through to executor.runOp() which routes
  them via agent-channel (no SSH target or direct connection needed).

- 2026-06-28: FEAT — DB Ops: 5 new PDB operations in services/db-ops-executor.js.
  After pdb.list: pdb.open (ALTER PLUGGABLE DATABASE {{pdb_name}} OPEN),
  pdb.open_readonly (OPEN READ ONLY), pdb.close (CLOSE IMMEDIATE, destructive),
  pdb.open_all (ALTER PLUGGABLE DATABASE ALL OPEN), pdb.save_state (SAVE STATE).
  All sysdba:true; pdb.open/open_readonly/close/save_state accept pdb_name param.

- 2026-06-28: FEAT — DB Ops instance/listener ops routed through agent-channel (oracle-proxy.py 3.21.4).
  services/db-ops-executor.js: 5 instance ops (startup open/mount/nomount, shutdown immediate/abort)
  and 4 listener ops (status/start/stop/reload) changed from type:'ssh'/commandKey to type:'agent'/agentOp.
  runOp() gains 'agent' branch: sendToAgent(connId, POST /api/ebs-ctrl, { op: agentOp }, 120s).
  renderPreview() + derivePrivilege() handle 'agent' type. routes/db-ops.js: getConnParams() adds
  connectionId field. oracle-proxy.py 3.21.4: 9 new ops added to _CTRL_ALLOWED (db_startup_open/mount/
  nomount, db_shutdown_immediate/abort, db_listener_status/start/stop/reload); new db_* dispatch block
  in /api/ebs-ctrl reads ORACLE_HOME from env, runs sqlplus -S / as sysdba with fixed stdin for
  startup/shutdown or lsnrctl for listener ops; listener ops 15-30s timeout, startup/shutdown 60-120s.

- 2026-06-28: FIX — DB Ops tabs: 'users' moved from Start/Stop to Maintenance tab.
  Start/Stop: instance, listener, pdb, asm, rac.
  Maintenance: objects, recyclebin, controlfile, scheduler, users.

- 2026-06-28: FEAT — DB Ops: Start/Stop tab + ORACLE_HOME-aware instance/listener templates.
  db-ops.html: 'admin' tab → 'Start / Stop' (🟢); 'instance'+'listener' moved to front of
  its categories; 'listener' removed from 'maintenance' tab.
  services/ssh-executor.js: all 5 oracle.instance.* templates now source ORACLE_HOME+ORACLE_SID
  from /etc/oratab and use $ORACLE_HOME/bin/sqlplus (fixes "sqlplus: not found" on SSH sessions
  with no Oracle PATH). All 6 oracle.listener.* templates (status/services/start/stop/reload/
  save_config) use same /etc/oratab sourcing + $ORACLE_HOME/bin/lsnrctl.

- 2026-06-28: FIX — connections-new.html: ?client= pre-fill + /msp link fix.
  Pre-fill IIFE now checks ?client= URL param when no sessionStorage value present;
  fetches client_name via /api/clients/clients/:slug, populates field with gold border,
  stores in sessionStorage for return flow. MSP client link href /msp → /clients.

- 2026-06-27: FIX — Clients: health check endpoint + styled rename modal.
  clients-detail.html: runHealthCheck() URL /api/health-check → /api/health-checks;
  success handler reads data.id and redirects to /report/:id if present.
  clients-dashboard.html: editClient() prompt() → tvInput() themed modal (dark surface,
  gold border, focus+select on open). tvInput()/tvInputResolve() added. tv-input-modal
  HTML added.

- 2026-06-27: FEAT — Clients dashboard: edit name, health check button, confirm modal.
  clients-dashboard.html: ✏️ rename button on each card calls editClient() → prompt() →
  PATCH /api/clients/clients/:slug (auto-slugifies new name). deleteClient() confirm()
  replaced with themed tvConfirm() modal (dark surface, gold border, red Confirm button).
  clients-detail.html: ▶ Health Check button per server row — POSTs /api/health-check,
  shows ⏳/✓/✗ states, reloads 1.5s after success.
  routes/clients.js: PATCH /clients/:slug — updates client_name + client_slug, user-scoped.

- 2026-06-27: FEAT — TuneBot streaming polish + follow-up chips.
  askAI(): tokens written via textContent during streaming (no per-token renderMarkdown);
  final renderMarkdown() fires once on stream done. credentials:include added to fetch.
  saveChatHistory(): persists last 20 turns to localStorage keyed by connection ID.
  addFollowUpSuggestions(): 3 keyword-matched chips after each AI reply (tablespace /
  invalid objects / RMAN / ADOP / stats / WF Mailer / log file sync); chip click removes
  row and fires sendUserMessage(). sendUserMessage(): sets input + calls submitQuestion().

- 2026-06-27: FEAT — TuneBot frontend SSE streaming.
  public/tunebot.js askAI(): replaces single-JSON fetch with SSE stream consumer.
  initStreamBubble() creates bubble shell on first byte (mascot + AI badge + context header).
  pump() reads r.body.getReader() chunks; each token appended to accumulated + renderMarkdown()
  called progressively. done event: speakResponse + history push. error event: inline red text.
  Network failure falls back to KB as before.

- 2026-06-27: FEAT — TuneBot /chat SSE streaming.
  POST /api/tunebot/chat now streams via SSE instead of returning a single JSON response.
  Sets Content-Type: text/event-stream + flushHeaders(); uses anthropic.messages.stream().
  Each content_block_delta text chunk emitted as data: {"token":"..."}\n\n.
  Completion sends data: {"done":true}\n\n. Errors send data: {"error":"..."}\n\n.

- 2026-06-27: FEAT — TuneBot system prompt: TuneVault features & navigation section added.
  routes/tunebot.js buildSystemPrompt(): new ## TuneVault Features & Navigation block injected
  after findings/tickets context and before closing DBA persona instructions. Covers all major
  pages (/clients, /connections, /db-ops, /ebs-ops, /tune, /vault, /tuneops, /mcp, /patches,
  /sql-console, /settings, /pricing etc.), key concepts (agent outbound-only model, EBS instance
  name grouping, client grouping for MSP, health score scale 0-100), and current pricing tiers.

- 2026-06-27: FEAT — TuneBot added to clients-dashboard.html and clients-detail.html.
  <script src="/tunebot.js"></script> added just before </body> in both pages.

- 2026-06-27: FIX — nav-component.js + tunebot.js: Servers/Add Server labels; Fleet → /clients.
  nav-component.js Servers dropdown: 'DB Connections' → 'Servers', 'Add Connection' → 'Add Server'.
  buildProductDropdown(): removed fleetHref/fleetDesc vars; Fleet Management item now links to
  /clients with inline desc based on isLoggedIn.
  tunebot.js fleet FAQ: answer updated to reference Clients Dashboard (/clients) with
  client/instance grouping and MSP use case description.

- 2026-06-27: FIX — Remove Fleet Overview tab from clients-dashboard.html.
  Tab bar, fleet panel, fleet CSS and all fleet JS (loadFleet/renderFleet/timeAgo/switchTab/
  fleetLoaded) removed. Client card grid shown directly without tab wrapper (-163 lines).

- 2026-06-27: FEAT — Rename MSP → Clients; Fleet tab merged into Clients dashboard.
  nav-component.js: Connections dropdown label → 'Servers'; Fleet nav removed; MSP → '🏢 Clients'
  at /clients; AUTH_TYPES updated: 'msp'/'msp-client' → 'clients'/'clients-detail'.
  server.js: requires routes/clients.js, mounts on /clients + /api/clients; 301 redirects
  from /msp → /clients and /msp/client/:slug → /clients/client/:slug.
  routes/clients.js: new file (renamed from msp.js) serving clients-dashboard.html +
  clients-detail.html; all [msp] log prefixes → [clients].
  public/clients-dashboard.html: two tabs — Clients (card grid) + Fleet Overview (lazy table
  grouping all connections by client → instance with score rings + Open links).
  public/clients-detail.html: back link '← Clients'; '+ Add Server' button →
  /connections/new?return=clients&client=:slug; slug extracted from /clients/client/:slug path.
  public/connections-new.html: showSuccessBanner() + saveCredentials() fast-path handle
  returnTo=clients (and legacy msp); read ?client= slug to redirect to /clients/client/:slug.

- 2026-06-27: FIX — connections-new.html: const clientName → let in saveCredentials().
  const prevented the fallback reassignment inside if (returnTo=msp) block from working,
  throwing "Assignment to constant variable". Changed to let so the MSP sessionStorage
  fallback assignment works correctly.

- 2026-06-27: FIX — connections-new.html: duplicate clientName var + readOnly removed.
  saveCredentials(): second clientName declaration inside if (returnTo=msp) block changed
  from var to bare assignment — const clientName already declared earlier in same function
  scope; re-declaration caused a JS error. Pre-fill IIFE: removed clientField.readOnly=true
  so user can correct the pre-filled client name if needed.

- 2026-06-27: FIX — connections-new.html: MSP client pre-fill highlight + context file timing fix.
  Pre-fill IIFE: when msp_pending_client is set, client name field gets gold border, readOnly=true,
  and label normalised to 'MSP Client Name' to prevent accidental edits.
  createServer() POST body: removed db_context_file + app_context_file — step 2 inputs are hidden
  (display:none) when createServer() runs so getElementById returns null; context files already
  injected into the install command via buildInstallCmd() which runs in step 2 when fields are
  visible. createServer() catch: added console.error('[createServer] error:', err) logging.
  connections-new.html showSuccessBanner() + saveCredentials(): when returnTo=msp, redirect to
  /msp immediately without rendering the success banner (no 2s delay).

- 2026-06-27: FEAT — MSP Delete Client button on each card.
  msp-dashboard.html: red-outline Delete button added to each client card footer.
  deleteClient(e, slug, name): confirm dialog clarifies servers are not deleted, only
  unassigned; calls DELETE /api/msp/clients/:slug; reloads client grid on success.
  routes/msp.js: DELETE /clients/:slug NULLs client_name + client_slug on all
  oracle_connections matching slug scoped to req.user.id.

- 2026-06-27: FIX — routes/msp.js: use overall_score instead of score in health_checks queries.
  Both LATERAL subqueries updated: SELECT score → SELECT overall_score AS score.
  Affects /api/msp/clients (rollup avg/min/critical/warning counts + per-conn score array)
  and /api/msp/clients/:slug (per-connection last score). Alias preserves h.score references
  in the outer SELECT and downstream JS unchanged.

- 2026-06-27: FIX — MSP return flow: preserve sessionStorage; refresh conn list on modal reopen.
  connections-new.html init IIFE: removed sessionStorage.removeItem('msp_pending_client') so the
  key survives for showSuccessBanner() to read as a fallback if the user never typed in the field.
  showSuccessBanner() when return=msp: clientName reads from field value OR sessionStorage
  fallback; sets msp_pending_client before redirecting to /msp after 2s.
  msp-dashboard.html empty-state "Add a connection first →": now links to /connections/new?return=msp
  with onclick saving modal-client-name to sessionStorage before navigating.
  msp-dashboard.html auto-open block: openAddModal() called immediately; 300ms setTimeout
  pre-fills client name, calls autoSlug() and loadConnsForModal() so the newly registered
  server appears in the checklist without a manual refresh.
  connections.html renderCard(): client/instance badge pills moved outside card-top div
  (was breaking the flex row); margin-top 4px → 6px.

- 2026-06-27: FIX — connections-new.html: style context file inputs; DB fields shown by default.
  Added .field-group input / :focus / ::placeholder / .field-label / .field-sub CSS rules to the
  page <style> block (values match existing .ctx-file-row input) so wizard-db-context and
  wizard-app-context render correctly — .field-input class is not defined on this page.
  Removed class="field-input" from both context file inputs.
  db-context-field and db-running-notice initial display changed none → block (DB server is the
  natural first selection); app-context-field remains display:none.

- 2026-06-27: FEAT — Add Connection wizard: DB/App context file fields + enriched install command.
  connections-new.html step 2: replaced single ctx-file-row with three new field groups.
  db-context-field (id=wizard-db-context, optional) — shown for db/both server types; maps to
  TUNEVAULT_DB_CONTEXT_FILE. db-running-notice — amber warning that Oracle must be running before
  install (shown for db/both). app-context-field (id=wizard-app-context, required asterisk) —
  shown for apps/both; maps to TUNEVAULT_EBS_CONTEXT_FILE.
  selectStype(): per-type show/hide for all three blocks (isDb/isApps flags); clears values on
  type deselect. buildInstallCmd(): reads both new inputs and injects env vars before bash.
  createServer() POST body: db_context_file + app_context_file included.
  routes/ssh-install.js POST /api/connections/draft: destructures db_context_file +
  app_context_file; builds install command via envVars array so context files are baked into the
  server-returned base command when provided at draft time.

- 2026-06-27: FIX — install.sh: EBS instance name from context file s_dbSid; agent v3.21.3.
  Removed hostname-stripping heuristic (strip -db/-app suffix from EBS_DB_HOST) which was
  unreliable on non-standard hostnames.
  Added DB_CONTEXT_FILE= placeholder to agent.env template.
  On db-type servers: interactive prompt for EBS DB context XML path
  (skipped in headless/non-interactive mode; TUNEVAULT_DB_CONTEXT_FILE env var bypasses prompt).
  INSTANCE_NAME derivation now follows a 5-step priority chain:
    1. TUNEVAULT_EBS_INSTANCE_NAME env var (explicit override)
    2. s_dbSid from EBS app context file (app/both servers)
    3. s_dbSid from DB context XML (db servers — new DB_CONTEXT_FILE path)
    4. First PMON SID (db servers with no context file)
    5. Interactive prompt (last resort, tty only)
  DB_CONTEXT_FILE persisted to agent.env via sed -i alongside INSTANCE_NAME.
  config/proxy-version.js + oracle-proxy.py: VERSION bumped 3.21.2 → 3.21.3.

- 2026-06-27: FEAT — MSP Add Client modal + connection card badges (UX polish).
  msp-dashboard.html renderConnChecklist(): now shows ALL connections (including already-assigned),
  not just unassigned ones. Already-assigned connections display "(currently: ClientName)" note below
  their name. Empty state now only triggers when allConns.length === 0, with a "Add a connection
  first →" link to /connections/new. Group select-all checkboxes use data-instance scoping so they
  are excluded from the saveClient() id list. "Add new connection" link added below the checklist in
  the modal. openAddModal() persists typed client name to sessionStorage (msp_pending_client) so the
  Add Connection wizard can pre-fill it.
  connections-new.html: Step 1 "Name your server" card gains an optional MSP Client Name text input
  (id=client-name-field). Init IIFE reads msp_pending_client from sessionStorage and pre-fills the
  field, then removes the key. saveCredentials() includes client_name + auto-generated client_slug
  in the PUT /api/connections/:id payload when non-empty.
  connections.html renderCard(): when client_name is set, a gold pill (🏢 ClientName) and an
  optional dim pill (⚙ EBSPROD) render below the server name on every connection card.
  connections.html submitEdit(): EBS Instance Name and MSP Client Name are now required — both
  labels show a red asterisk; save handler validates both fields before building the payload and
  focuses the empty field with an informative alert on failure.

- 2026-06-27: FEAT — MSP multi-client dashboard.
  New section for MSPs managing multiple Oracle client fleets under one TuneVault account.
  DB: oracle_connections gains client_name TEXT + client_slug TEXT (ensureColumns at startup).
  routes/msp.js: GET /msp (dashboard page), GET /msp/client/:slug (client detail page),
  GET /api/msp/clients (rollup: conn count, instance count, avg/min score, critical/warning
  counts, per-connection score array), GET /api/msp/clients/:slug (connections grouped by
  ebs_instance_name). Both API routes scoped to req.user.id.
  server.js: app.use('/msp') + app.use('/api/msp') mounted before 404 handler.
  middleware/security.js: updateConnectionSchema accepts client_name + client_slug.
  routes/connections-list.js: GET /connections SELECT includes client_name/client_slug
  (withNewCols guarded); PUT handler persists both; RETURNING includes them.
  public/msp-dashboard.html: client card grid with health dots (green ≥80, amber 50-79,
  red <50), status badge (All healthy / X warnings / X critical), Add Client modal that
  picks unassigned connections and batch-PUTs client_name + client_slug.
  public/msp-client.html: grouped by ebs_instance_name; DR badge for names matching
  _DR or STANDBY; score rings; DB/App/Both type badges; last-check timestamps;
  Open Dashboard → links per connection.
  public/connections.html edit form: MSP Client Name + Client Slug fields; auto-slug
  generation on name input (oninput lowercase+hyphenate); submitEdit sends both.
  public/nav-component.js: MSP link after TuneOps in authenticated nav; 'msp' +
  'msp-client' added to AUTH_TYPES.

- 2026-06-27: FIX — tunebot.js: Switch server button no longer stretches full width.
  tb-conn-select div is now a block wrapper; actual button is an inline-flex <button> appended
  inside it so it sizes to content. Popup rect reads from btn. Container gains padding-top:2px.

- 2026-06-27: FIX — tunebot.js: header badge left-border accent + full-width; Switch server inline-flex.
  Badge: red bg 0.1→0.08, colored left border (green/amber/red), border-radius:4px, full-width
  box-sizing. Switch server button: inline-flex + align-items:center + gap:4px, padding 2px 8px→3px 10px.

- 2026-06-27: FEAT — tunebot.js: panel visual polish — deeper bg, stronger border, glow shadow, header bg.
  Panel: bg rgba(18,18,26,0.98), border opacity 0.4→0.6, triple box-shadow (deep drop + outer ring +
  gold glow). Header div: darker bg rgba(10,10,16,0.95) + subtle gold bottom border.

- 2026-06-27: FIX — tunebot.js: panel positioning — hardcoded width, wider margin, safeLeft clamp.
  getPanelAnchor: panelW hardcoded to 420 (no offsetWidth reliance); margin 8→20.
  openPanel: safeLeft = max(8, min(anchor.left, innerWidth-440)) prevents right-edge overflow.
- 2026-06-27: FIX — tunebot.js: panel viewport overflow — computed left replaces right:20px.
  tbWidgetPos branch clamps to innerWidth-428. Default branch: left=max(8, innerWidth-428-20)
  so 420px panel always stays within viewport. 428 = 420px + 8px margin.
- 2026-06-27: FIX — tunebot.js: getPanelAnchor fallback width 390 → 420 to match updated panel width.
- 2026-06-27: FIX — tunebot.js: panel border changed to accent gold rgba(240,168,48,0.4).
- 2026-06-27: FIX — tunebot.js: context badge max-width 160px → 100% (badge now has its own full-width row).

- 2026-06-27: FIX — tunebot.js: header column layout — badge/Switch server no longer overlap buttons.
  Outer header div changed to flex-direction:column. Mascot+title+status and right buttons wrapped
  in an inner flex row (justify-content:space-between). Context badge and conn-select moved below
  the inner row so they never compete with NEW/Voice/minimize/X for horizontal space.
  Panel widened 390px→420px.

- 2026-06-27: FIX — tunebot.js: header button overflow on mobile.
  Outer header row: gap:8px + min-width:0. Left side div: flex:1 + min-width:0 so it
  shrinks on narrow screens. Context badge: max-width 200px → 160px. Right buttons
  div: flex-shrink:0 so buttons never compress or wrap.

- 2026-06-27: FIX — tunebot.js: conn selector popup escapes widget overflow:hidden.
  Popup appended to document.documentElement instead of document.body. position:fixed
  → position:absolute with top/left computed using window.scrollY/scrollX offsets.
  Dismiss handler uses getElementById so popup is found regardless of mount point.

- 2026-06-26: FIX — ebs-ops.html: ADOP workers param min=1; params builder applies p.min.
  workers entry gains min:'1'. selectAdopPhase() params builder sets input.min = p.min
  when defined — generic support for any future numeric param with a minimum.

- 2026-06-26: FEAT — ebs-ops.html: dark-theme styling for ADOP params form fields.
  New .adop-phase-params-field class: #0d0d10 background, var(--border) border, accent
  focus ring, option elements styled for dark theme. Both input/select className
  assignments in selectAdopPhase() params builder changed from form-control to
  adop-phase-params-field.

- 2026-06-26: FEAT — ebs-ops.html: dedicated .btn-adop-phase / .btn-adop-phase-danger CSS classes.
  New classes with hover/disabled states: .btn-adop-phase (yellow solid, lift+shadow on hover) and
  .btn-adop-phase-danger (red outline, fills red on hover). Phase grid buttons use them directly
  without .btn base class. Run button changed to .btn-adop-phase with dynamic label 'Run <Phase>'
  set in selectAdopPhase(). Back button stays .btn.btn-outline.

- 2026-06-26: FIX — ebs-ops.html: ADOP phase buttons match EBS Ops styling.
  Phase grid: btn-sm → btn-outline. Abort button styled red via inline
  style="color:var(--red);border-color:rgba(248,113,113,0.4)" (style cleared
  from ADOP_PHASES data entry). Back button: btn-sm → btn-outline.
  Run ADOP Phase: btn-primary → btn-run (yellow, matches all other Run buttons).

- 2026-06-26: FIX — report.html: PDF filename + print header + blank page fixes.
  document.title re-set in PDF button click handler (not just at load time) so nav
  resets can't overwrite it before window.print() fires. exportConnName module-level
  var populated in showDownloadButton() for use at click time.
  #print-header div added at top of <body>: display:none on screen, display:flex in
  @media print. Contains TV logo, report subtitle (Oracle/EBS App Tier), download
  timestamp; subtitle switches on exportIsAppTier.
  @media print: html/body overflow:visible + min-height:unset; #report-root /
  .report-container / main / .main-content height:auto + padding/margin-bottom:0
  to eliminate blank trailing page from layout min-height reservations.

- 2026-06-26: FIX — PDF export: replace Puppeteer server-side PDF with window.print().
  public/report.html: PDF button changed from server route link to window.print() call.
  document.title set dynamically in showDownloadButton() to tunevault-{connName}-{YYYYMMDD}
  so browser print dialog proposes the correct filename. @media print CSS updated: html/body
  height:auto; .tab-panel:not(.active) hidden; .tunebot-widget + .download-buttons +
  #download-buttons hidden; -webkit-print-color-adjust:exact enforced.
  routes/reports.js: all 3 page-pdf routes removed. puppeteer/chromium requires removed.
  package.json: @sparticuz/chromium and puppeteer-core uninstalled (-728 lines net).
  Dockerfile retained for Docker deployment option (CHROME_PATH=/usr/bin/google-chrome-stable).

- 2026-06-25: FIX — page-pdf: switch to system Chromium via puppeteer-core; remove @sparticuz/chromium.
  routes/reports.js: replaced @sparticuz/chromium dependency with system Chrome discovery.
  getChromePath() checks CHROME_PATH env var first, then runs
  `find /opt/render/.cache/puppeteer -name "chrome" -type f | head -1` (Render hardcoded HOME),
  falls back to /usr/bin/google-chrome-stable. PUPPETEER_LAUNCH_OPTS changed to factory function
  so executablePath resolves lazily at request time. All 3 routes log '[page-pdf] Chrome path: <path>'
  before launch for visibility in Render logs. Browser launch uses headless:'new' + --no-sandbox +
  --disable-dev-shm-usage. @sparticuz/chromium and @playwright/test uninstalled from package.json.
  render-build.sh added: npm install + npx puppeteer browsers install chrome (no sudo required).
  .npmrc min-release-age=4320 removed (was blocking installs due to system clock offset).

- 2026-06-25: FIX — page-pdf: A4 multi-page format, show all tabs, explicit on-demand hiding.
  routes/reports.js (all 3 page-pdf routes): switched from single-page fixed-height PDF to A4
  multi-page format (format:'A4', 24px/32px margins). Added printCss injection with break-inside:
  avoid on all card types (.finding-item, .exec-summary-card, .rec-item, .addm-finding-card,
  .ebs-check-card, .metric-card, .section, .vitals-bar, .score-section), h2/h3 break-after:avoid,
  body/html height:auto min-height:0, container padding-bottom:8px. Cleanup evaluate block now
  shows ALL tab panels (display:block, add active class) then explicitly hides 19 on-demand
  selectors (blocking, longops, addm, housekeeping, topsqlbkd, invalidobj, unusableidx, stalestats,
  parameters, scheduler, expiredusers, dataguard, recyclebin, dblinks + load-btn classes) and
  .placeholder-title containers. Removes fixed/sticky elements, grain overlay, details elements,
  inactive finding collapsed bodies. Score-section elements get break-inside:auto to avoid page 1
  gap. emulateMediaType('screen') added after waitForSelector to prevent @media print unhiding all
  tab panels before JS cleanup runs. setViewport({1280,900}) after emulateMediaType. Viewport back
  to height:900. /:connectionId/page-pdf: waitForFunction added to block until #tab-performance
  innerHTML > 500 chars; settle wait 500ms→2000ms.

- 2026-06-25: FIX — Remove EBS app-tier findings from DB health check report.
  public/report.html: removed EBS Application Status card from AI Summary tab (static render +
  poll updater + timeout handler + EBS spinner safety net). Removed buildEbsFindings() function
  and Application Critical (EBS) subsection (CM status, WF errors, WF notification backlog are
  app-tier concerns). Simplified poll stop condition to stop when summary_text ready. Simplified
  Critical Issues block to use critCount directly. Removed EBS Operations tab button and tab-ebsops
  panel. Removed Apps Environment (ENV VARS MISSING) section from renderEbsOpsTab. Exec-summary
  EBS text updated to point to EBS Summary section instead of removed tab. Net: -225 lines.

- 2026-06-25: FEAT — static sample-ebs-report.html + expanded DB sample report.
  public/sample-report.html: complete rewrite with EBSDB/Oracle 19c/EBS 12.2 data. Score ring
  72/100 yellow. 10 tabs: Overview, Storage (13 tablespaces), Performance (wait events + top SQL),
  Backups (RMAN not configured), EBS (CM 21 processes + WF DEACTIVATED_SYSTEM), Security, Memory
  (SGA 2GB/83.8% buffer hit), Config (NEW — init params + OS stats), Indexes (NEW — 100/100),
  AI Analysis. PDF CTA: email input removed, single button → /api/reports/demo/page-pdf.
  public/sample-ebs-report.html: new page in app-tier format (no score ring). Header: prod-ebs-
  app.example.com + EBS APP TIER badge + 72/100 score box. 4 metric cards: CRITICAL 2 / WARNINGS
  2 / CHECKS PASSED 11/15 / Status Online. 2 tabs: AI Summary + EBS Checks. PDF → /api/reports/
  demo/ebs-page-pdf. routes/reports.js: demo/page-pdf + demo/ebs-page-pdf routes added before
  /:connectionId/page-pdf (ordering prevents 'demo' being parsed as connectionId).

- 2026-06-24: FEAT — /docs page + docs.tunevault.app host routing.
  public/docs.html: dark-theme docs page (Space Grotesk, sticky sidebar, scroll-spy active state).
  3 sections: Getting Started (prerequisites table, agent install curl command, 5-step add-connection
  flow, first health check); Understanding Your Health Report (score range cards 80-100/60-79/40-59/0-39,
  finding severity table, AI summary explanation, diagnostic SQL code block); Security & Trust
  (outbound-only architecture diagram, catalog-view read list, DML/DDL never-do list, source
  verification commands + SHA-256 links). CTA box links to /connections/new + /trust.
  server.js: GET /docs route serves docs.html; docs.tunevault.app host middleware runs before all
  other routes so any path on the subdomain returns docs.html.
  nav-component.js: docs:1 added to AUTH_TYPES; "📚 Docs" item added to Resources dropdown
  (after Setup Guide).

- 2026-06-24: FIX — Simplify report download buttons; replace Playwright with window.print().
  public/report.html: injectExportButtons() rebuilt — DB report: 3 buttons (Download PDF via
  window.print(), Export CSV, DB XLSX); app-tier report: 2 buttons (Download PDF, App Tier XLSX).
  Removed broken EBS PDF, Full Report, Export PDF, EBS XLSX buttons from DB path. @media print CSS
  added: hides nav/buttons/tab-bar/tab-pills, forces white bg, shows all tab panels so full report
  prints on all pages.
  public/report-ebs.html: injectSplitDownloadButtons() kept for EBS PDF + EBS XLSX; DB PDF / DB
  XLSX / Full Report buttons removed; exportPdf() button and injectToolbarExportButtons() call removed.
  routes/reports.js: /page-pdf server route added (puppeteer-core + @sparticuz/chromium v149,
  ESM default export via chromiumPkg.default||chromiumPkg, chromium.args spread, headless:'shell').
  Primary PDF button calls window.print() — browser print dialog is simpler and more reliable on
  Render (512MB RAM) than headless Chromium for this use case.
  package.json: playwright removed from dependencies; @sparticuz/chromium@^149.0.0 +
  puppeteer-core@^22.0.0 added to dependencies; @playwright/test remains in devDependencies.

- 2026-06-24: FIX — Remove AQ-based WF checks and non-existent views.
  services/ebs-12-2-checks.js: removed topo.wf_deferred_queue + topo.wf_error_queue (queries
  APPLSYS.WF_DEFERRED_TABLE_M / WF_ERROR_TABLE_M — AQ internal tables, not standard-grantable
  on EBS 12.2.12); kept topo.wf_mailer_status (FND_SVC_COMPONENTS). Check count 29→27.
  public/trust.html: removed APPLSYS.WF_DEFERRED_TABLE_M, WF_ERROR_TABLE_M, FND_OAM_MANAGED_TYPES
  from APPS views list and grants SQL block.

- 2026-06-24: DOCS — Add confirmed APPS grants to oracle-setup for EBS environments.
  db/setup/tunevault_reader.sql Step 4: 17 confirmed APPS grants (FND_CONCURRENT_QUEUES through
  FND_PRODUCT_INSTALLATIONS), uncommented with note to run as APPS + skip ORA-00942.
  public/oracle-setup.html: new "EBS only" section with all 17 grants in copy-paste block + note.

- 2026-06-24: FIX — Route NodeManager + WLS TCP probes through agent-channel.
  TCP probes via net.connect() from Render always fail for proxy connections (Render cannot reach
  customer's internal network). routes/ebs-deep.js: net.connect(5556) NodeManager → sendToAgent
  (node_manager_status, 10s timeout); net.connect(7001) WLS → sendToAgent(wls_admin_status, 15s).
  Both fall back to UNKNOWN/'Agent unreachable' on throw. queryEbsDeepStatus gains
  { appTierConnId, appsPwd, wlsPwd } 4th param. oracle-proxy.py 3.21.2: node_manager_status
  added to _CTRL_ALLOWED; handler runs ps -ef | grep NodeManager.

- 2026-06-24: FIX — ebs-deep module checks: convert columnar agent rows to objects.
  routes/ebs-deep.js: toObjectRows(result) helper detects columnar format (Array.isArray(rows[0]))
  and converts via result.columns; falls through for object rows. Applied to checkFndNodes,
  checkAdopFilesystem, checkProfileOptions, checkCmConfig, checkWfMailer.

- 2026-06-24: FEAT — Upgrade AI models: gpt-4o for health analysis, claude-sonnet-4-6 for TuneBot.
  server.js: gpt-4o-mini → gpt-4o at all 4 health/EBS/advisory completion call sites. routes/tunebot.js:
  claude-haiku-4-5-20251001 → claude-sonnet-4-6.

- 2026-06-24: FIX — Increase AI analysis timeout to 90s; reduce max_tokens to 2500.
  server.js: OpenAI client timeout 55000→90000ms; health analysis max_tokens 4000→2500; race
  timeout 55s→90s. Reduces gpt-4o latency without meaningful quality loss for Oracle HC summaries.

- 2026-06-23: FEAT — /oracle-proxy.py.sha256 verification endpoint + live hash on trust page. Commit c9a92bc.
  server.js: GET /oracle-proxy.py.sha256 reads oracle-proxy.py at request time, computes SHA-256, returns
  "sha256:<hex>" as text/plain with Cache-Control: no-store (same pattern as /install.sh.sha256).
  trust.html: source callout fetches /oracle-proxy.py.sha256 on page load; injects hash into #proxy-sha256
  span; shows "loading…" while fetching, hash on success, "unavailable" on error.

- 2026-06-23: FIX — Trust page accuracy: agent source links, APPS views, WF checks. Commit 3de4e95.
  trust.html: agent source callout added under Architecture section (oracle-proxy.py browser/download links,
  install.sh, uninstall.sh, SHA-256 link). APPS views list expanded 8→20 views to include APPLSYS.WF_DEFERRED_TABLE_M,
  WF_ERROR_TABLE_M, AD_TRACKABLE_ENTITIES, FND_NODES, AD_ADOP_SESSIONS, AD_ADOP_SESSION_PATCHES,
  FND_PROFILE_OPTIONS/VALUES, FND_SVC_COMP_PARAM_VALS_V, FND_CONCURRENT_QUEUE_SIZE, FND_OAM_MANAGED_TYPES;
  grants block extended to match. Command whitelist badge changed from "unauthenticated" to
  "Sample commands — sign in to see full live whitelist (55+ ops)". Lockdown bundle button disabled;
  contact security@tunevault.app text added. Retention card updated to connection-scoped with DELETE purge
  instructions.
  services/ebs-12-2-checks.js: topo.workflow_mailer_status (was querying non-existent wf_all_agent_activity)
  replaced with three correct checks — topo.wf_deferred_queue (APPLSYS.WF_DEFERRED_TABLE_M error state +
  depth), topo.wf_error_queue (APPLSYS.WF_ERROR_TABLE_M count), topo.wf_mailer_status (FND_SVC_COMPONENTS
  WHERE component_type='WF_MAILER'). Check count 27 → 29.

- 2026-06-23: FIX — ebs-12-2-checks: rebuild around 6-module structure; revert ebs-deep to pre-redesign. Commits f8735b6, b9cd246.
  ebs-deep.html reverted to pre-redesign state — module diagnostics section removed; original Monitor/Sanity/
  Control/SSH tabs restored.
  ebs-12-2-checks.html rebuilt around the 6-module structure (Concurrent Processing, Workflow & Mailer,
  ADOP/Patching, App Tier Services, Security/Hygiene, Topology/Configuration). Calls
  /api/ebs-deep/modules/run; auto-loads latest run on page open; expandable module cards with per-check
  evidence and recommendations; summary bar with overall status. Card description in ebs-ops.html updated
  to reflect the 6-module design.

- 2026-06-23: FIX — ebs-12-2-checks: pass apps_pwd + weblogic_pwd to ebs-ctrl; resolve wlsPwd from stored connection. Commits a944352, 62dcdd3, 27da56e, 0246861, ed5e170, e48eb3d.
  Multiple iterative fixes to make ebs-12-2-checks fully operational:
  - routes/ebs-12-2-checks.js: getConnParams() selects weblogic_pwd_enc + apps_pwd_enc; findPairedDbConn()
    prefers APPS user when set; /run handler logs cred presence before runEbs122Checks(); passes appsPwd +
    wlsPwd + appTierConnId through.
  - services/ebs-12-2-checks.js: runEbs122Checks() signature gains appsPwd + wlsPwd; ctrlChecks runner
    sends apps_pwd + weblogic_pwd in ebs-ctrl body. topo.oacore_count + topo.forms_count switched from
    FND_NODES SQL to ebs-ctrl oacore_status/forms_status (requires:'ctrl'); topo.wls_topology switched
    from SSH-executor to ebs-ctrl wls_admin_status. Runner now splits checks into ctrlChecks (via
    agent-channel /api/ebs-ctrl, 90s timeout) and sshOnlyChecks (SSH executor). Agent-channel SQL format
    corrected; nav opacity + TNS/SSH badge cleanup; nav init type + proxy detection + apps-conn filter
    + auto-resolve SSH target fixes in UI.

- 2026-06-23: FEAT — EBS Deep module diagnostics: normalized 6-module workspace. Commit 7fe6297.
  Diagnosed broken EBS Deep state: POST /api/ebs-deep/run (called by "Run Deep EBS Check") never existed
  → 404; services/ebs-12-2-checks.js (27 checks) was wired to no route; TCP probes for NodeManager/OPMN/
  WLS ran from Render (wrong host for proxy connections); stale "Phase 1 · Dry-run" framing on page.
  New 6-module architecture: Concurrent Processing, Workflow & Mailer, ADOP/Patching, App Tier Services,
  EBS Security/Hygiene, Topology/Configuration.
  Normalized check contract: check_id, label, status (ok|warn|crit|info|error), evidence, recommendation,
  source_type, safe_to_run. Module result: module_id, module_label, overall_status, summary, checks[], ran_at.
  routes/ebs-deep.js: POST /api/ebs-deep/modules/run assembles all 6 modules in parallel via
  Promise.allSettled; GET /api/ebs-deep/modules/latest/:id for persistence.
  db/ebs-module-runs.js + migration 1750640000000: new ebs_module_runs table.
  public/ebs-deep.html: Monitor tab rebuilt with 6 module cards; auto-expands warn/crit modules; auto-loads
  latest run on tab switch; service status grid demoted to secondary section.
  tests/ebs-deep-modules.test.js: 30 tests (20 unit + HTTP auth guards + demo structure).

- 2026-06-23: FIX — EBS Deep status/sanity/reports: route proxy connections through agent-channel. Commit a612b0d.
  db/ebs-deep.js: getConnectionById returns server_type, ebs_instance_name, apps_pwd_enc.
  routes/ebs-deep.js + ebs-deep-reports.js: inlineBinds/makeProxyQueryFn/findPairedDbConnForEbs helpers
  added; queryEbsDeepStatus + runOracleSanityChecks + runEbsDeepQueries refactored to accept optional
  queryFn. Proxy path for POST /api/ebs-deep/status, /sanity/run, and /api/ebs-deep/run now routes SQL
  through agent-channel instead of returning demo data. App-tier connections pair to DB-tier agent via
  ebs_instance_name matching. Demo data kept only for connection_id=0 or ?demo=1.

- 2026-06-22: FEAT — 5 new Tune advisors: compression, index usage, space/tablespace, statistics, AWR wait events. Commit df202cc.
  - routes/compression-advisor.js: DBA_SEGMENTS + DBA_TABLES >100MB uncompressed; OLTP/HCC savings estimates.
  - routes/index-advisor.js: V$OBJECT_USAGE unused indexes + DBA_IND_COLUMNS prefix-redundancy detection.
  - routes/space-advisor.js: DBA_TABLESPACE_USAGE_METRICS fullness + autoextend flags + DBA_FREE_SPACE
    fragmentation + AWR growth trend; critical=85%+no-autoextend.
  - routes/stats-advisor.js: DBA_TAB/IND_STATISTICS stale/missing with ready-to-run DBMS_STATS commands;
    Statistics Advisor findings (12.2+, non-fatal on ORA-00942).
  - routes/awr-advisor.js: DBA_HIST_SYSTEM_EVENT top waits + class rollup + DBA_HIST_SYSSTAT; flags
    hard-parse >10% + disk-sort >1%; ORA-00942 → licensed:false.
  5 matching dark-theme HTML pages with connection picker, run button, results tables. server.js: 5 route
  mounts + 5 page routes. tune.html: Space & Schema Advisors section + 5 cards. nav-component.js: all 5
  types in AUTH_TYPES + _tunePages.

- 2026-06-22: FEAT — SQL Access Advisor, Segment Advisor, Memory Advisor — new Tune hub advisors. Commit 5776f65.
  - routes/access-advisor.js: DBA_ADVISOR_TASKS (SQL Access Advisor, last 30d) + DBA_ADVISOR_RECOMMENDATIONS
    + DBA_ADVISOR_ACTIONS; top 10 tasks, top 20 recs; proxy + direct TCP; ORA-00942 → licensed:false.
  - routes/segment-advisor.js: DBA_ADVISOR_FINDINGS (Segment Advisor, last 7d) + DBA_SEGMENTS >1GB;
    ORA-00942 → licensed:false.
  - routes/memory-advisor.js: V$MEMORY_TARGET_ADVICE + V$SGA_TARGET_ADVICE + V$PGA_TARGET_ADVICE;
    individual view failures non-fatal (view empty when AMM/ASMM not active).
  server.js: 3 route mounts + 3 page routes. tune.html: 3 new cards. nav-component.js: 3 new
  AUTH_TYPES entries + _tunePages highlights. Dark-theme HTML pages with unlicensed-panel for Tuning/
  Diagnostics Pack gating.

- 2026-06-22: FEAT — ETCC reimplementation: DB tier in DB Ops, MT tier in EBS Ops; oracle-proxy.py updates. Commits 4bd9ebd, c388e3f, 5f9e004.
  oracle-proxy.py: etcc_run_db_bg — runs checkDBpatch.sh as oracle user via su; ORACLE_HOME/SID/
  LD_LIBRARY_PATH/PATH exported from agent.env before invocation; ORACLE_HOME baked into su command at
  request time so environment survives the su boundary. contextfile param passed to checkDBpatch.sh.
  DB Ops: ETCC DB-tier card in db-ops.html (checkDBpatch.sh via agent). EBS Ops: ETCC MT-tier card in
  ebs-ops.html (checkMTpatch.sh via apps_tier). routes/ebs-ops.js: ETCC route wiring. Proxy version bump.

- 2026-06-21: FIX — EBS env sourcing: TWO_TASK → EBSapps.env run.
  All 16 EBS SSH templates: APPS_BASE from /etc/tunevault/agent.env; source "$APPS_BASE/EBSapps.env" run.
  oracle-proxy.py 3.20.76: _TAIL_ALLOWED_PATH_PREFIXES gains /app/, /data/, /oradata/, /db/, /u05/, /u06/.

- 2026-06-20: FIX — Remove API key tier gate — pay for connections not features. Commits 7f1222b, c00e8f7, e560926.
  Individual plan ($49/conn) now gets full API key access (10 req/min). Free tier (no active plan) is the
  only tier blocked from the REST API. Three commits landed in sequence:
  (7f1222b) settings-api.html: loadKeys() catch block shows exact "API error: HTTP <status> — <body>"
    in #keys-error div instead of a generic toast; temporary debug fetch button added.
  (c00e8f7) URL fix: all 5 fetch calls in settings-api.html corrected from /api/keys → /settings/api/keys
    — the router is mounted at /settings so /api/keys was always 404. Duplicate app.use('/', settings-api)
    mount removed from server.js.
  (e560926) Tier gate fully removed: routes/settings-api.js — 403 gate deleted from POST /api/keys +
    /regenerate; TIER_RATE_LIMITS individual 0→10; TIER_ACCESS individual wording updated; has_access
    hardcoded true (auth middleware is the actual gate). settings-api.html: upgradeGate div + .upgrade-gate
    CSS removed; render() always shows apiContent; debug button removed (diagnostic work done).
    pricing.html: REST API v1 Individual column — → 10 req/min.

- 2026-06-20: FEAT — MCP UX: canonical /mcp/setup route, nav fix, page reorder, tests. Commit b9b546c.
  Routing: /mcp/setup is now the canonical human setup URL (routes/mcp.js GET /setup handler);
  /mcp-setup returns 301 → /mcp/setup for backward compat; discovery JSON setup field updated.
  All internal refs updated: index.html footer, settings-api.html.
  Nav critical fix: mcp.html was missing <nav id="nav-root"> — nav-component.js loaded but had
  no element to inject into; nav never rendered on /mcp/setup. Fixed: added nav element + sticky
  CSS. SSO emoji changed 🔑→🏢 (was colliding with API Keys entry in account dropdown).
  mcp.html reordered: setup steps now come before tool grid (configure first, explore second).
  4 steps: API key → Claude Code → Claude Desktop → Verify. Step 3 is a new verify step with
  first prompt. Step 4 adds inline troubleshooting note. Auth section simplified (removed
  duplicate Generate API Key button). Tool grid moved after setup.
  tests/mcp-routes.test.js: 10 assertions covering discovery JSON, /mcp/setup 200, /mcp-setup
  301, POST /mcp 401 shape, /settings/api auth guard.

- 2026-06-20: FIX — Admin API key access, Settings nav, mcp.html generate key button, settings hub.
  Commit 9f461b5. middleware/auth.js: kirankumar.ale@gmail.com hardcoded as fallback admin so
  /settings/api works without ADMIN_EMAILS env var. settings-api.html: #keys-error div added above
  tier gate; catch block in loadKeys() populates it with the actual error message (was toast-only).
  nav-component.js: Settings hub link (⚙️ /settings) added before Billing in account dropdown.
  mcp.html: .btn + .btn-primary CSS added; "Generate API Key →" button added after Step 1 alert.
  settings.html: "Integrations & Developer" section added with API Keys (/settings/api),
  Notifications (/settings/notifications), SSO (/settings/sso) cards; icon-purple CSS class added.

- 2026-06-20: FIX — mcp.html + settings-api.html use standard nav-component; mcp-setup + settings-api in AUTH_TYPES.
  Commit 5e72c0b. public/mcp.html: custom hardcoded nav HTML removed; replaced with nav-component.js
  + window.tvNav.init('mcp-setup'). Custom footer links replaced with standard vault.html footer pattern.
  public/settings-api.html: console.error added to loadKeys catch block. public/nav-component.js:
  'mcp-setup':1 and 'settings-api':1 added to AUTH_TYPES.

- 2026-06-20: FIX — API keys URL corrected (/settings/api-keys → /settings/api); API Keys added to nav dropdown.
  Commit ec82401. public/mcp.html: all 5 /settings/api-keys hrefs corrected to /settings/api (nav link,
  step-1 body text, auth section body, CTA button, footer). routes/mcp.js: comment header, discovery JSON
  auth field, and 401 error message updated. public/nav-component.js: API Keys added to user account
  dropdown after SSO — buildDropdownItem('/settings/api', '🔑 API Keys', 'Manage API keys for Claude
  and REST API access').

- 2026-06-20: FEAT — TuneVault MCP server: 8 Oracle tools for Claude Desktop and Claude Code.
  Commit beb7af3. Implements MCP Streamable HTTP transport (stateless, no SDK — plain JSON-RPC 2.0).
  No npm install needed; protocol implemented inline using Express + plain fetch patterns.
  routes/mcp.js: POST /mcp (JSON-RPC endpoint), GET /mcp (discovery JSON).
  Auth: Bearer <api-key> validated against api_keys table via getUserByApiKey().
  8 tools:
    list_connections — oracle_connections for the authenticated user.
    run_health_check — link + instructions to trigger a HC from web UI.
    get_latest_health_check — overall_score, summary_text, top_action, critical/warning
      check_results from the most recent health_checks row.
    security_posture_scan — latest compliance_reports row (type='security_posture')
      with per-group findings and score.
    list_tuneops_tickets — tuneops_tickets filtered by company_id (from user email domain),
      optional connection_id/status/severity/limit.
    run_sql_query — SELECT-only (BLOCK_SQL regex) via agent-channel sendToAgent
      /api/run_sql; proxy connections only; 200-row cap; markdown table output.
    get_vulnerability_exposure — live scan: queries v$instance + dba_registry_sqlpatch
      via agent-channel, cross-references data/oracle-patches.json CVE index, returns
      unfixed CVEs sorted by severity + OPatch runbook for highest-priority patch.
    get_resilience_status — latest compliance_reports row (type='resilience').
  public/mcp.html: dark-themed setup guide with copy buttons, tool reference table,
    Claude Desktop (~/.claude/claude_desktop_config.json) and Claude Code (.mcp.json)
    config examples, example prompts.
  server.js: app.use('/mcp', require('./routes/mcp')) + GET /mcp-setup → mcp.html.
  public/index.html: "MCP Server" link added to footer after "Terms".
  public/settings-api.html: subtitle links to /mcp-setup.
  Note: @modelcontextprotocol/sdk not installed (project .npmrc has before=2014 date
  lock). Protocol is straightforward JSON-RPC 2.0 — no SDK dependency needed.

- 2026-06-20: FIX — SQL Console: fully remove Explain Plan (second pass — all CSS, function, references).
  Commit 028d9c8. First removal pass (d3e9da4) removed the button, tab, window.explainSql, and
  extraKey binding, but left behind: .btn-explain CSS block (18 lines), .plan-output/.plan-header/
  .plan-separator CSS block (14 lines), explainSql() function body (48 lines), stale references to
  Ctrl+E in textarea placeholder and keyboard-shortcuts comment.
  This pass removes everything remaining:
  - .btn-explain + :hover + :disabled CSS rules deleted
  - /* ── Explain plan ── */ section + .plan-output/.plan-header/.plan-separator CSS deleted
  - explainSql() async function deleted entirely
  - textarea placeholder: "Ctrl+E for explain plan" → "F5 for run script"
  - Keyboard shortcuts comment: "Ctrl+Enter/Ctrl+E/Ctrl+L" → "Ctrl+Enter/Ctrl+L/F5"
  grep-verified zero remaining references to explainBtn, btn-explain, explainSql, tab-explain,
  plan-output, plan-header, plan-separator, or Ctrl-E after this commit.

- 2026-06-20: FIX — SQL Console: actionable error message for Oracle BC date (-4712) query failures.
  Commit 60bdc11. When the oracle-proxy agent returns an error for a query (result.success === false),
  routes/console.js previously returned the raw Python error string (e.g. "year -4712 is out of
  range") which is cryptic to users. New check: before the generic error return, inspect result.error
  for '-4712', 'out of range', or 'year'; if matched, return a structured actionable message:
    "Query contains Oracle BC dates (year -4712) that cannot be displayed. Add a filter:
     WHERE your_date_column > DATE '0001-01-01' or use TO_CHAR(your_date_column) to convert to string."
  Returns rows:[], cols:[] alongside the error so the client renders a clean error panel.
  Complements the client-side [BC date] row-value guard (d3e9da4) and the agent-side _safe_val()
  fix in oracle-proxy.py 3.20.75 — three layers of defence for BC date data.

- 2026-06-20: FIX — SQL Console: remove Explain Plan, BC date client-side guard, 500-row limit warning.
  Three fixes landed in commit d3e9da4.
  (1) Explain Plan removed end-to-end. EXPLAIN PLAN requires INSERT INTO PLAN_TABLE which is a write
  operation incompatible with the tunevault_reader least-privilege model. Removed: "Explain" button
  from editor toolbar, 'Ctrl-E' from extraKeys, Ctrl+E from capture-phase and document-level keydown
  handlers, the "📐 Explain Plan" tab button, the #tab-explain <div>, window.explainSql assignment,
  and "Ctrl+E explain" from the shortcuts hint bar. The explainSql() function body is retained but
  unexposed. Shortcuts bar now reads: Ctrl+Enter run · F5 run script · Ctrl+L clear.
  (2) Oracle BC date guard in routes/console.js proxy path row mapper: any string value that includes
  '-4712' (Oracle's internal epoch, year −4712) is replaced with the literal '[BC date]' before being
  returned to the client. This is a temporary client-side bridge until oracle-proxy.py 3.20.75
  (which has the _safe_val() server-side fix) propagates to all agents.
  (3) 500-row hard cap in routes/console.js proxy path: after mappedRows is built, if length > 500
  the response slices to the first 500 rows and includes truncated:true, rowCount:<total>,
  truncatedAt:500. Client (sql-console.html runSql branch): renderTable() called with displayRows;
  if data.truncated an amber warning banner is appended below the table: "⚠ Showing first 500 of N
  rows. Add WHERE or FETCH FIRST N ROWS ONLY to limit results." Results count badge shows "500+"
  when truncated. --amber and --amber-dim CSS variables added to :root.

- 2026-06-20: FEAT — SQL Console: SQL Developer parity (cursor-aware run, selection run, script mode, statement count).
  Commit a8d9556.
  (1) getCurrentStatement(): checks _cm.somethingSelected() first — if true, returns trimmed selection
  with trailing ; stripped (run-selection mode). Otherwise splits the full editor content on '\n',
  accumulates lines into statements delimited by lines ending with ';' or end-of-buffer, builds a
  stmtLineRanges array ({start, end} per statement), then finds which range contains cursor.line and
  returns that statement with trailing ; stripped. Fallback: last statement. runSql() calls
  getCurrentStatement() in place of getEditorSql().trim().
  (2) Run Script (F5): "▶▶ Script" green button added to toolbar (btn-script CSS class — rgba green
  tint matching btn-explain pattern). runScript() splits full editor content on ';', filters empty,
  runs each via executeSqlApi(connId, stmt) sequentially, renders per-statement header + result inline
  in #tab-results. executeSqlApi(connId, sql) extracted as shared helper (avoids duplicating authFetch
  logic). F5 added to extraKeys in initEditor() and to the document-level capture-phase listener.
  window.runScript + window.getCurrentStatement exposed.
  (3) Statement count indicator: stmtCount <span> added to editor footer. _cm.on('change') handler
  (previously the results-clear handler, removed this session) updated to count semicolons and set
  e.g. "2 statements". Editor hint bar updated to include F5.

- 2026-06-20: FIX — SQL Console: CodeMirror.fromTextArea, clear-on-edit removed, getEditorSql, Try it no auto-run.
  Commit 381a2ec.
  (1) initEditor() switched from CodeMirror(div, {value:ta.value,...}) to CodeMirror.fromTextArea(ta,
  {...}). fromTextArea hides the textarea and inserts the CodeMirror wrapper immediately after it;
  handles textarea sync automatically — removed the manual _cm.on('change', ()=>ta.value=_cm.getValue())
  handler. lineNumbers: true (was false). value: removed from config (fromTextArea reads textarea value).
  Ctrl-Up / Ctrl-U history keys removed from extraKeys per spec.
  (2) _cm.on('change') results-clear handler removed. Results stay visible while the user edits SQL —
  SQL Developer behaviour. Stale results only clear when a new query is explicitly run.
  (3) runSampleQuery() no longer calls runSql() after loading the sample. Sets editor content, focuses,
  and stops — user presses Run or Ctrl+Enter when ready. .trim() added to sample text.
  (4) getEditorSql() adds || '' on the textarea fallback path. runSql() clears #tab-results to a
  "⏳ Running..." paragraph immediately after the empty-sql guard, before any async work, for instant
  visual feedback.

- 2026-06-20: FIX — CodeMirror self-hosted in public/vendor; unpkg.com removed from CSP.
  Problem: CodeMirror was loaded from https://unpkg.com/codemirror@5.65.16/* — an external CDN fetch
  on every SQL Console page load, requiring unpkg.com in helmet CSP scriptSrc/styleSrc/fontSrc.
  Fix: downloaded three files from unpkg at build time and committed to public/vendor/:
    codemirror.min.js  (402KB) ← lib/codemirror.js
    codemirror.min.css (8.7KB) ← lib/codemirror.css
    codemirror-sql.min.js (59KB) ← mode/sql/sql.js
  Note: unpkg.com does not publish .min.js variants for codemirror 5.x; files saved as .min.js for
  naming consistency with the original HTML references.
  sql-console.html: three CDN tags replaced with /vendor/* self-hosted paths.
  middleware/security.js: 'https://unpkg.com' removed from scriptSrc, styleSrc, fontSrc — /vendor/*
  is served from 'self' which is already in all three directives.

- 2026-06-20: FIX — SQL Console: keyboard shortcuts (Ctrl+Enter), app-tier SQL routing, oracle-proxy.py 3.20.75.
  Multiple fix iterations across routes/console.js and public/sql-console.html:

  Keyboard shortcuts:
  - extraKeys bound function references at CodeMirror init time when functions were not yet defined.
    Changed all extraKeys to wrapper lambdas: `function(cm) { window.runSql && window.runSql(); }`.
  - window.* assignments block moved to BEFORE initEditor() call. Function declarations are hoisted
    so all functions exist at assignment time; CodeMirror wrapper lambdas resolve correctly.
  - _cm.focus() called after initEditor() so keyboard events land in the editor immediately on page load.
  - Capture-phase keydown listener added to the CodeMirror wrapper element (prevents browser default on
    Ctrl+Enter — Chrome submits forms).
  - Document-level capture-phase keydown fallback added (guards click-then-keyboard pattern where focus
    drifted to the Run button rather than the editor): fires when window._cm is initialized OR
    document.activeElement is inside .CodeMirror. Covers E/L uppercase and lowercase variants.
  - window._cm assigned inside initEditor() right after CodeMirror construction.
  - Tab extraKey simplified to `cm.replaceSelection('    ', 'end')` (removed broken cursor-position
    branch that referenced undefined `e`).

  App-tier SQL routing (server_type='apps' connections have no Oracle driver on the agent):
  - getConnParams() SELECT adds ebs_instance_name; returned object adds ebsInstanceName.
  - New getPairedDbConn(appConn, userId) helper: queries oracle_connections WHERE ebs_instance_name=$1
    AND server_type IN ('db','both') AND connection_type='proxy'. Was filtering AND proxy_url IS NOT NULL
    — agent-channel connections have proxy_url=NULL so this always returned null (no paired DB found).
    Fixed to AND connection_type='proxy'. SELECT also returns host, port, service_name.
  - Returned object includes host/port/serviceName from the paired DB row.
  - Run path: when serverType='apps', calls getPairedDbConn(); routes sendToAgent to pairedDb.id;
    uses pairedDb.host/port/serviceName in the body (app-tier connections have host=NULL).
  - Explain path: same routing applied to both EXPLAIN PLAN and DBMS_XPLAN.DISPLAY calls.
  - executeSQL(connParams, sql, maxRows, timeoutMs) gains fifth param userId (default null);
    call site passes req.user.id; getPairedDbConn uses userId not req.user.id (req is not in
    scope inside executeSQL — was a ReferenceError on every app-tier query).
  - Credential resolution: username = connParams.username || (appsPassword ? 'APPS' : null);
    password = connParams.password || appsPassword. App-tier specific error message when missing.

  oracle-proxy.py 3.20.75:
  - _safe_val(v) helper defined inside /api/run_sql row serialization loop. Wraps str(v) in
    try/except (ValueError, OverflowError). Oracle BC dates (e.g. year -4712 from V$INSTANCE
    startup_time on some configs) cause Python's datetime.__str__() to raise ValueError: year -4712
    is out of range. Returns '[date out of range]' placeholder instead of crashing with HTTP 500.
  - config/proxy-version.js LATEST_PROXY_VERSION bumped to 3.20.75.

- 2026-06-19: FIX — SQL Console proxy path: map agent array rows to named objects — was returning all NULL.
  Root cause: agent /api/run_sql returns { success, columns: ['COL1','COL2',...], rows: [['v1','v2',...], ...] }
  — rows are arrays, not objects. routes/console.js proxy return block was passing result.rows (raw arrays)
  directly to the frontend. The SQL Console table renderer expects {COL1: 'v1', COL2: 'v2'} objects and used
  Object.keys(row) to build headers and row[col] to read values — both returned nothing for arrays, rendering
  every cell as NULL. Fix: after receiving the agent response, map rows via
    Object.fromEntries(cols.map((col, i) => [col, row[i] ?? null]))
  Applied to both the run endpoint (executeSQL proxy return, ~line 288) and the explain endpoint plan rows
  (planLines mapping in the /explain proxy block). The explain fix also replaces the prior Object.values(row)
  approach which accidentally worked for single-column DBMS_XPLAN results but was semantically wrong.

- 2026-06-19: FIX — oracle-proxy.py 3.20.74: ORACLE_HOME bootstrap before oracledb import; SQL Console explicit credentials; privilege_model default→reader; trust page accurate disclosure; EBS Ops privilege bar.
  Five changes in one commit:
  (1) oracle-proxy.py: Reads /etc/tunevault/agent.env (or /opt/tunevault/agent.env) for ORACLE_HOME and
  LD_LIBRARY_PATH using os.environ.setdefault() BEFORE importing cx_Oracle or oracledb. Previously agent.env
  was read after the import block, so oracledb.init_oracle_client() never saw ORACLE_HOME and always fell back
  to thin mode on DB servers. The bootstrap loop is placed immediately before the cx_Oracle import block using
  a private namespace (_os, _env_path, _ef, _el, _ek, _ev) to avoid polluting the module-level namespace.
  (2) routes/console.js proxy path (both /run and /explain): Added up-front guard — if connParams.username or
  connParams.password is missing, returns a clear actionable error message directing the user to edit the
  connection and add tunevault_reader credentials. When credentials are present, sends them explicitly with
  os_auth:false. Reverts the prior os_auth:true approach which connected as SYSDBA — SQL Console must always
  use explicit Oracle credentials for least-privilege compliance.
  (3) server.js line 2874: privilegeModel default changed from 'sysdba' to 'reader'. This affects the greyed-out
  n/a sections on the report page for connections where privilege_model cannot be looked up.
  (4) public/trust.html: The "No SYS or SYSTEM password" item now accurately discloses that agent-only
  connections (no stored credentials) use OS authentication — equivalent to running sqlplus / as sysdba locally,
  the same pattern as Oracle Enterprise Manager agents. An exception note was added to the Least-Privilege
  compliance card. Previously the page claimed "we never ask for or store SYSDBA credentials" which was
  inaccurate for agent-only connections.
  (5) public/ebs-ops.html: Privilege disclosure bar added below page title: "EBS service control runs as applmgr
  (OS user) via the on-server agent. EBS SQL operations use APPS credentials. SSH Terminal uses your configured
  OS user." Links to /trust. Styled as a subtle dark-theme info bar (.priv-disclosure-bar CSS added).
  config/proxy-version.js LATEST_PROXY_VERSION bumped to 3.20.74.

- 2026-06-19: FIX — SQL Console + SSH Terminal: window scope, CSP, nav cleanup, global credentials:include.
  Completing the SQL Console + SSH Terminal fix series started on 2026-06-18:
  (1) public/sql-console.html DOMContentLoaded scoping: entire inline script wrapped in DOMContentLoaded
  callback. This made all function definitions local scope — unreachable from onclick= HTML attributes.
  Fixed by assigning all 12 handler functions to window.* (runSql, runSampleQuery, explainSql, clearEditor,
  copySql, switchTab, sortResults, exportCsv, onConnChange, loadHistory, loadFromHistory, cycleHistory).
  (2) middleware/security.js CSP: 'https://unpkg.com' added to scriptSrc, styleSrc, fontSrc. Required for
  CodeMirror loaded from the unpkg CDN. Without this, CSP blocked the editor from loading.
  (3) initEditor() wrapped in try/catch with console.warn fallback so loadConnections() still runs if
  CodeMirror fails to initialize (e.g., CDN blocked or element not ready).
  (4) Nav: SSH Terminal moved to Connections dropdown (was missing). Recent Reports section removed from
  Connections dropdown (was 44-line block showing recent HC links with score badges — clutter). SQL Console
  and SSH Terminal cards removed from DB Ops Tools tab (they appear in nav already — no need to duplicate).
  (5) Global credentials:include: Node.js fix script (scripts/fix-fetch-credentials.js) added
  credentials:'include' to all fetch('/api/...) calls across 43 HTML files in public/ (111 calls total).
  A template-literal regex bug in the script corrupted 17 lines across 10 files (${ credentials:... }
  instead of ${ credentials:... } — repaired with a PASS 1 regex before re-applying the fix).
  Script deleted after use.

- 2026-06-18: FEAT — SQL Console + SSH Terminal: agent-channel routing, session-cookie auth, connection picker.
  routes/console.js:
  - Added const channel = require('../services/agent-channel').
  - Proxy path for /api/sql-console/run: replaced callProxy() direct HTTP with channel.sendToAgent(connId,
    {method:'POST', path:'/api/run_sql', body:{...}}, timeoutMs). Avoids NAT — Render cannot reach Oracle
    VMs directly; agent polls Postgres and executes locally.
  routes/terminal.js:
  - Fixed recursive requireSeniorDbaOrAdmin bug: function was calling itself instead of requireRole('senior_dba')
    → would cause stack overflow on every non-admin request.
  - Added _runViaAgentChannel(connectionId, target, authConfig, cmd, timeoutMs) using channel.sendToAgent to
    /api/ssh/exec. Both /run and /stream proxy blocks now use _runViaAgentChannel.
  public/sql-console.html:
  - Auth: removed COOKIE_NAME, getCookie(), manual Authorization: Bearer header. HttpOnly session cookies are
    invisible to JS; correct approach is credentials:'include' on fetch. authHeaders() now returns only
    Content-Type.
  - CodeMirror ID mismatch: getElementById('cmEditor')→getElementById('sqlEditor'). The div#cmEditor was a
    CSS target only; CodeMirror must be initialized on the actual textarea.
  - style='display:none' removed from textarea (CodeMirror requires element visible at init time).
  - loadConnections() rewritten: direct fetch with credentials:'include', filters removed_connection,
    auto-selects from ?connection= param then localStorage.
  - tvNav.init('db-ops')→tvNav.init('sql-console').
  public/terminal.html:
  - Connection picker (connFilter <select>) added above SSH Target picker; loads /api/connections in parallel
    with /api/terminal/targets. _connections state added.
  - onConnFilterChange() filters SSH targets by connection_id, calls _clearForContextChange().
  - _clearForContextChange(): clears termOutput + resets _histIdx (guarded against initial load).
  - clearOutput(): also resets _histIdx, clears cmdInput, focuses input.
  - Ctrl+L shortcut added to keydown handler.
  - CSS: overflow-x:hidden→auto; max-height:calc(100vh - 200px) added.
  - MAX_LINES: 1000→500.
  admin/ssh-targets.html: credentials:'include' added to saveCredentials fetch call (credentials were
  silently not saving — fetch was sending session cookie-less requests).

- 2026-06-18: FIX — ADDM + Performance: Oracle 19c columns, hot segments query, embed mode, history.
  Multiple fix iterations on routes/addm.js and addm.html:
  - Correct 19c column names from DBA_ADVISOR_FINDINGS: IMPACT (not IMPACT_PERC), no BENEFIT column on 19c.
  - Filter completion/internal rows: WHERE category NOT IN ('FINDING CREATION') AND type IN ('PROBLEM','SYMPTOM').
  - Blank finding titles: use FINDING_NAME when MESSAGE is empty.
  - Privilege banner hidden in agent/proxy mode (banner requires direct DBA_ADVISOR access).
  - ADDM history: null-connId guard (was NaN from uninitialized state); fetch error caught and displayed.
  - addm router mounted at /api/connections prefix so GET /api/connections/:id/addm/runs resolves correctly
    (was mounted at / root, so path was not matched).
  - History spinner: only shown when connId is valid.
  routes/performance.js:
  - Hot segments: switched from v$segment_statistics (not accessible to tunevault_reader) to dba_segments
    (SELECT_CATALOG_ROLE covers it). Fixed demo data and comments.
  - IMPACT_PERC→IMPACT column fix in ADDM-adjacent queries.
  - :windowH bind variable inlined for agent-channel path (agent /api/run_sql doesn't support named binds).
  DB Ops + ADDM: iframe embed mode — nav hidden when page loaded inside an iframe (embed=1 param).

- 2026-06-18: REFACTOR — Nav: Tune hub; Performance/ADDM/Patches removed from top-level nav.
  nav-component.js: Performance, ADDM, and Patches links removed from the primary navigation bar.
  These pages are now accessible via the Tune hub page and direct URLs. Motivation: nav bar was overflowing
  on smaller viewports; tuning-related pages are secondary to the core HC/EBS/Vault workflow.
  The Tune hub page aggregates ADDM, Performance Advisor, and Patch Advisor in one place.
  AUTH_TYPES preserved so auth redirect still works for direct URL access.

- 2026-06-18: FEAT — Landing page: Vault+Tune feature pillars, pricing strip removed, Individual 10 conns.
  public/index.html:
  - Vault pillar: Security Posture, Vulnerability Exposure, Compliance reports.
  - Tune pillar: ADDM, Performance Advisor, Patch Advisor.
  - Pricing strip removed from landing (was showing stale plan prices).
  - Individual plan copy updated to 10 connections (was 5).
  - Polsia AI references scrubbed (Polsia is an internal proxy; not customer-facing).
  - Stale pricing copy corrected throughout.

- 2026-06-18: FEAT — Resilience tab: RMAN backup, archivelog, Data Guard, FRA, redo log checks.
  New "Resilience" tab added to the 9-tab health check dashboard. Five check groups:
  - RMAN backup currency: last full/incremental backup age, warns >24h, critical >7d.
  - Archivelog mode: ARCHIVELOG vs NOARCHIVELOG (critical if NOARCHIVELOG in production).
  - Data Guard: detect standby config, transport/apply lag.
  - FRA usage: v$recovery_file_dest space_limit vs space_used percentage.
  - Redo log sizing: log_switch_interval, group sizes, switches-per-hour rate.

- 2026-06-18: FEAT/FIX — Admin: SSH Targets in nav, upgrade badge, agent-upgrades reliability fixes.
  Admin nav dropdown (nav-component.js / admin sub-nav): SSH Targets link added. Consistent sub-nav
  verified across all admin pages (admin-agents, proxy-self-test, ebs-validation, ssh-targets, test-harness).
  Connections list + admin agents page: proxy version upgrade badge displayed when installed agent version
  < LATEST_PROXY_VERSION (yellow pill showing "→ 3.x.x available").
  Admin 500 errors fixed: admin agents page, proxy-self-test, and ebs-validation were returning 500 due to
  a connection filter bug introduced in a prior refactor.
  agent-upgrades: HTML route handler was registered before API JSON route, intercepting /api/admin/agent-upgrades
  requests and returning HTML. Fixed by reordering route registration.
  Duplicate upgrade audit rows: added unique constraint enforcement on INSERT; prevented race condition where
  two heartbeats in quick succession both triggered upgrade rows.
  Clear-failed button added to admin agent-upgrades UI — resets failed/stuck upgrade records so auto-upgrade
  retries on next heartbeat.
  AUTO_UPGRADE_TARGET string in server.js now sourced from LATEST_PROXY_VERSION constant in config/proxy-version.js
  (was a hardcoded string that drifted from the config).

- 2026-06-17: FIX — Security Posture vault history UX improvements + PDF/XLSX column width fixes.
  public/vault.html:
  - Scan history panel loads on page mount (was only populated after running a new scan).
  - Last completed scan result auto-reloaded when page is refreshed (persisted via localStorage).
  - Clicking a previous scan row in the history panel reloads that scan's full result on screen.
  - Previous scans table: each row now shows its own scan timestamp (was always showing the latest scan time).
  - Stale result cleared and spinner shown in the results area immediately when a new scan starts (previously
    old result stayed visible until new result arrived — misleading during long scans).
  pdf-generator.js: Security Posture table column widths capped; text wrapping enabled so long group names
  and finding text no longer overflow cell boundaries in PDF export. XLSX export: column widths set explicitly
  (autoFit was under-sizing columns for long text).
  Connections list + admin agents page: proxy version upgrade badge (feat commit e384df2, 2026-06-17 22:41) —
  yellow pill showing available version when installed proxy < LATEST_PROXY_VERSION.

- 2026-06-17: SECURITY — oracle-proxy.py 3.20.73: fix sqlplus_query shell injection.
  Root cause: sqlplus_query in _EXEC_WHITELIST used argv_template:
    ["sh", "-c", "echo \"{sql}\" | {ORACLE_HOME}/bin/sqlplus -S / as sysdba"]
  Even though subprocess.run() was not called with shell=True, the SQL was interpolated into
  the string argument passed to sh -c. A quote-and-semicolon payload (e.g. `"; cmd; echo "`)
  broke out of the echo quoting and executed arbitrary shell commands as the Oracle OS user.
  The sql_safe validator only checked the prefix (SELECT/SHOW/ALTER SYSTEM), leaving the body
  completely unchecked.
  Fix — three layers:
  (1) stdin_mode for sqlplus_query: argv_template changed to
        ["{ORACLE_HOME}/bin/sqlplus", "-S", "/ as sysdba"]
      plus new spec fields stdin_mode=True, stdin_arg="sql". SQL is piped to sqlplus via
      subprocess stdin (input= kwarg); it never appears in a shell string. /exec handler
      detects stdin_mode and appends "\nEXIT\n" before passing to run_os_command().
  (2) run_os_command() gains optional stdin_input=None param. When set, encodes to UTF-8
      and passes as input= to subprocess.run(); all other callers unaffected.
  (3) Full SQL body validation in _validate_exec_arg() for sql_safe type: after the prefix
      regex passes, rejects any of: ';' '--' '/*' '*/' '\x00', and word-boundary matches
      for EXECUTE/EXEC/BEGIN/DECLARE (case-insensitive). Max 4000 chars.
      New module-level constants: _SQL_SAFE_DISALLOWED (list), _SQL_SAFE_DISALLOWED_WORDS_RE
      (compiled regex), _SQL_SAFE_MAX_LEN=4000.
  Defense-in-depth: /api/run_sql applies identical disallowed-token checks before
  cursor.execute(). No shell path there (uses cx_Oracle/python-oracledb directly), but
  blocks statement-stacking attempts (';' in SQL body) at the HTTP layer.
  config/proxy-version.js LATEST_PROXY_VERSION bumped to 3.20.73.

- 2026-06-17: FIX — Security Posture persist: widen compliance_reports CHECK constraint.
  Root cause of scan-not-saved: compliance_reports.report_type CHECK only allowed
  (sox_change, access_audit, activity_summary); INSERT for 'security_posture' violated it.
  The catch block in routes/security-posture.js was swallowing the error silently, so the
  missing scan_id meant ⤓ PDF/XLSX buttons never rendered and Previous Scans stayed empty.
  Fix: migrations/1781740800000_compliance_reports_add_security_posture_type.js —
    DROP CONSTRAINT IF EXISTS compliance_reports_report_type_check;
    ADD CONSTRAINT compliance_reports_report_type_check
      CHECK (report_type IN ('sox_change','access_audit','activity_summary','security_posture'));
  Migration auto-runs on Render deploy via "npm run build" → "node migrate.js"
  (migrate.js tracks applied migrations in _migrations table; runs each once in timestamp order).
  Also: routes/security-posture.js catch block now sets result.persist_error = err.message and
  logs full err (not just message). vault.html: red "⚠ Scan not saved: <msg>" note below score
  so this class of failure can never silently vanish again. History guard fixed:
  data.scans.length < 2 → === 0 (panel was suppressed until 2+ scans existed).

- 2026-06-16: FIX — Security Posture scanner: 7 reliability fixes.
  routes/security-posture.js:
  (1) Sequential execution: Promise.all over 10 check groups replaced with sequential for…of await.
  Agent channel is serial — parallel calls flooded the queue, causing random timeouts and scores that
  were higher on failure (errored groups silently dropped from denominator). Now deterministic.
  (2) sendToAgent timeout raised 30000→60000 ms in makeProxyQueryFn.
  (3) Partial findings: every single-query check function now returns errorStatus(err.message) —
  'error' (ORA-00942/insufficient privileges), 'timeout', or 'partial' — instead of hardcoded 'error'.
  checkAccountHygiene split into two independent try/catch blocks: sql1 (DBA_USERS) failure →
  error status, score=null. sql2 (DBA-privileged users) failure → 'partial' status, open-user
  findings from sql1 preserved.
  checkPrivEscalation: run() helper already independent; removed early-return on anyRows===null;
  replaced with allPEFailed flag. Findings from succeeded sub-queries shown even if anyRows failed.
  Returns peStatus = allPEFailed ? errorStatus(primaryPEErr) : (primaryPEErr ? 'partial' : derive…).
  (4) Fail-closed scoring: computeOverallScore() now tracks errorCount for scored groups with
  score===null (optional groups still excluded). Returns {score, incomplete, errorCount}. POST route
  sets result.incomplete=true + result.incomplete_message='Incomplete — N check group(s) could not
  run' when any scored group failed. Score is computed over groups that succeeded only (not penalized
  for timeout), but the incomplete flag prevents presenting a clean number as a full-scan result.
  (5) vault.html badge taxonomy: statusLabels adds partial:'PARTIAL', timeout:'TIMEOUT'. CSS adds
  .status-partial (amber/accent-dim) and .status-timeout (muted grey). 'NEEDS GRANT' still reserved
  for 'error' status (ORA-00942 / insufficient privileges). Incomplete warning banner injected below
  scan meta (amber, "⚠ Incomplete — N check group(s) could not run").
  (6) Oracle-maintained roles exclusion: SQL_ANY, SQL_ADMIN, SQL_EXEC_PKGS in checkPrivEscalation
  add: AND grantee NOT IN (SELECT role FROM dba_roles WHERE oracle_maintained='Y')
  This excludes built-in roles (DBA, DATAPUMP_IMP_FULL_DATABASE, EXP/IMP_FULL_DATABASE, OLAP_DBA,
  TIMESERIES_DBA, EM_EXPRESS_ALL, AQ_ADMINISTRATOR_ROLE, etc.) that were leaking in as 'dangerous'.
  (7) result.scan_id confirmed: saveScan() returns RETURNING id; result.scan_id = scanId set before
  res.json() — ⤓ PDF / ⤓ XLSX buttons render on the just-completed scan.
  public/vault.html: incomplete warning banner, partial/timeout CSS + statusLabels entries.

- 2026-06-16: FEAT — Vault tab-bar scroll fix + Security Posture expanded to 10 groups (Tier A).
  public/vault.html .vault-tab-bar: background rgba(10,10,12,0.95) + backdrop-filter blur(12px) to
  match nav's treatment (z-index 90); content no longer bleeds through on long scan result scroll.
  routes/security-posture.js: 5 new check groups added; all 10 run via Promise.all; WEIGHTS
  rebalanced to sum=100:
    sp_default_passwords (15%), sp_priv_escalation (15%), sp_public_grants (12%),
    sp_init_params (12%), sp_password_policy (10%), sp_audit_trail (10%),
    sp_encryption (10%), sp_account_hygiene (8%), sp_db_links (4%), sp_patch_currency (4%).
  New group details:
    sp_init_params: 11 V$PARAMETER security params — remote_os_authent, o7_dictionary_
    accessibility, remote_os_roles, os_roles, sec_return_server_release_banner, resource_limit,
    remote_login_passwordfile (must=exclusive), sec_max_failed_login_attempts (0 or >10 warned),
    utl_file_dir (non-empty warned).
    sp_priv_escalation: 5 SELECTs — DBA_SYS_PRIVS ANY, DBA_SYS_PRIVS ADMIN OPTION,
    DBA_ROLE_PRIVS to PUBLIC, V$PWFILE_USERS non-SYS SYSDBA/SYSOPER, DBA_TAB_PRIVS EXECUTE on
    DBMS_JAVA/DBMS_SQL/UTL_FILE/DBMS_LOB/DBMS_SCHEDULER. All filter oracle_maintained='Y'.
    sp_encryption: V$ENCRYPTION_WALLET (status, wallet_type), DBA_TABLESPACES unencrypted
    non-system tablespaces, DBA_ENCRYPTED_COLUMNS count. 0-row wallet → -30; CLOSED → -25 crit;
    AUTOLOGIN → -10 info.
    sp_db_links: DBA_DB_LINKS — PUBLIC links -30 each (max -60, critical); fixed-user links
    (username set, non-PUBLIC) -10 each (max -30, warning).
    sp_patch_currency: V$INSTANCE version_full (18c+) or V$VERSION fallback + DBA_REGISTRY_SQLPATCH
    MAX(action_time). Null → -30 warning; >24 months → -40 critical; >12 months → -20 warning.
  db/setup/tunevault_reader.sql Step 5: full view-to-group table with 11g explicit-grant notes.

- 2026-06-16: FIX — Security Posture scanner: route proxy connections through agent, not oracledb.
  Root cause: makeQueryFn branched on !!conn.proxy_url (could be null) instead of connection_type.
  db/security-posture.js: getConnectionForScan now SELECTs server_type + ebs_instance_name.
  routes/security-posture.js: split into makeProxyQueryFn(targetConnId, sqlCreds) +
  makeDirectQueryFn; added findPairedDbConnWithCreds() (copies ebs-ops.js pattern); POST route
  branches on connection_type==='proxy' FIRST — db-type proxy uses conn's own creds;
  apps-type routes to paired DB agent via ebs_instance_name with DB credentials loaded from
  paired oracle_connections row. Up-front isAgentConnected check with 5s race returns 503
  before running 5 check groups. No proxy version bump — uses existing /api/run_sql.

- 2026-06-16: FEAT — Security Posture per-group transparency: status badges + Check details panels.
  routes/security-posture.js: added deriveStatus(score,findings)→pass/warn/fail/error helper.
  makeProxyQueryFn + makeDirectQueryFn now return {rows, columns, rawRows} (columns = string[],
  rawRows = array-of-arrays). All 5 check functions capture each SQL string into a `queries`
  array as {sql, columns, rawRows} on success or {sql, error} on failure; return includes
  `status` (deriveStatus) and `queries`. sp_account_hygiene + sp_audit_trail have 2 entries
  each (AUDIT_UNIFIED_POLICIES secondary still silently caught but captured). Error returns
  always set status:'error' — never render as pass. makeDirectQueryFn derives column names
  from result.metaData (oracledb 6+).
  vault.html: CSS added for .posture-status-badge (.status-pass/warn/fail/error), check
  details collapsible (.posture-check-details / -hdr / -body / -toggle), raw table, SQL block,
  error msg, .pf-rem-label. renderPostureResult group cards now show status badge (PASS/WARN/
  FAIL/NEEDS GRANT) between name and score. Findings show "Remediation" label above remediation
  block. buildCheckDetailsHtml(queries) builds collapsible "↳ Check details" panel (default
  collapsed) per group; toggleCheckDetails() controls it. Errors show SQL + red error box;
  0-row results show "0 rows returned"; rows shown in monospace table (capped at 25 rows).
  Footer /security link → /trust.

- 2026-06-16: FEAT — /security → /trust consolidation. routes/security.js GET / now 301-redirects
  to /trust (all other /security/* routes unchanged: /commands, /lockdown.sh, /lockdown-bundle.tar.gz).
  public/trust.html: 4 sections merged from security.html — (01b) Credential Storage by Connection Mode
  table; (04b) EBS Command Whitelist with live-fetch from /api/ebs-control/catalog; (04c) Audit Trail
  schema + sample row; (04d) Threat Model attack matrix. TOC updated with 4 new anchors.
  Claim fixes: "no agent on the DB host itself" → accurate (proxy runs on Oracle/app server);
  SVG "NO SSH FROM US" → "NO INBOUND SSH" (ssh_sqlplus mode does SSH outbound); SVG + data
  cards AI mentions: "GPT-4o-mini" → "GPT-4o-mini + Claude AI" (both OpenAI + Anthropic).
  OS support: added "Amazon Linux 2/2023, Debian" to lockdown script tested list.
  Hero CTA: Command Whitelist → href /security → #commands (inline section anchor).
  Footer: /security link → /vault. CSS additions: hardwall-note, cmd-table, schema, audit-g classes.

- 2026-06-16: FEAT — /vault hub: Security Posture scanner moved out of /security.
  public/vault.html: new page at /vault — hero + sticky tab bar (Security Posture active;
  Vulnerability Exposure + Resilience coming-soon placeholders) + full scanner UI (picker,
  Run Scan, score ring, group cards, history). nav-component.js: "Vault" link added after
  EBS Ops in auth app nav; "Security" removed from all 3 nav variants; public navs keep
  "Trust" → /trust only. server.js: GET /vault route added. security.html: scanner section
  + all posture CSS/JS removed; page reverts to pure CISO trust content.
  API route /api/security-posture unchanged — UI host only.

- 2026-06-16: FEAT — Security Posture usability fixes: nav link, placement, proxy support.
  nav-component.js: "Security" link added to all 3 nav variants (auth app nav with active-state
  highlight, logged-in public nav, unauthenticated public nav). security.html: Security Posture
  scanner section moved to top of page (above the fold), immediately after the hero; eyebrow
  updated to "Security Posture" (no number prefix); section has top border accent to distinguish
  from hero. Connection picker now shows ALL user connections (direct + proxy/agent); proxy conns
  labelled "[agent]". routes/security-posture.js: makeQueryFn() abstraction — proxy connections
  route each SELECT through channel.sendToAgent → /api/run_sql (isAgentConnected checked with
  5s race); direct connections use oracledb thin-client. Both paths return {rows:[{COL:val}]}
  so scoring/finding_history/UI unchanged. No new proxy op; no version bump.
  db/security-posture.js: getConnectionForScan now SELECTs proxy_url column.

- 2026-06-16: FEAT — Security Posture Phase 1a: read-only Oracle DB security scanner.
  db/security-posture.js: getConnectionForScan (ownership check), saveScan → compliance_reports,
  getHistory, upsertFindings → finding_history, resolveStaleFindings.
  routes/security-posture.js: POST /api/security-posture/:id/run (requireAuth, direct connections
  only) runs 5 check groups in parallel via oracledb thin client:
    sp_default_passwords (25%) — DBA_USERS_WITH_DEFPWD ACCOUNT_STATUS=OPEN
    sp_public_grants (20%) — DBA_SYS_PRIVS GRANTEE=PUBLIC, 16 dangerous privilege types
    sp_account_hygiene (20%) — DBA_USERS no-auth accounts + DBA privilege count
    sp_password_policy (20%) — DBA_PROFILES DEFAULT: verify fn, lockout, expiry
    sp_audit_trail (15%) — V$PARAMETER audit_trail + AUDIT_UNIFIED_POLICIES + DDL logging
  Weighted average → score 0-100. Persists to compliance_reports (report_type='security_posture'),
  upserts findings to finding_history (check_id LIKE 'sp_%'), resolves stale findings.
  GET /api/security-posture/:id/history → last 10 scans.
  server.js: app.use('/api/security-posture', ...).
  public/security.html: new Section 08 — Security Posture, auth gate (login prompt if not
  authed), connection picker (direct connections only), Run Scan button, SVG score ring,
  5 group cards (collapsible, severity-colored findings with remediation), history list.
  db/setup/tunevault_reader.sql: Step 5 comment documents required views for scanner
  (all covered by SELECT_CATALOG_ROLE + SELECT ANY DICTIONARY already in Step 2).

- 2026-06-16: FEAT — oracle-proxy.py 3.20.72: systemd transient scope for bg job cancel.
  Startup probe _HAS_SYSTEMD_RUN: checks systemd-run + systemctl exist + proxy is root +
  systemctl is-system-running returns 0/1. _launch_bg_job (and apps_stop/start_all_bg
  inline path): when _HAS_SYSTEMD_RUN, wraps job in `systemd-run --scope --quiet
  --collect --unit=tv-ebsjob-<JID> --uid=<applmgr>` so the whole cgroup (including
  re-spawned adstrtal workers) is reachable from one unit name; writes unit name to
  /tmp/tv_ebs_jobs/<JID>/scope. apps_job_cancel Path A (scope file exists): systemctl
  kill SIGTERM → 3s → systemctl is-active check → SIGKILL if still active → systemctl
  stop → verify inactive (killed=true). Path B (no scope): unchanged pgid/script_pid
  escalation from 3.20.71. Outcome reporting: cancelled:true only on confirmed death;
  still_running:true without TV_DONE if process survives. ebs-ops.html: cancel confirm
  wording updated to "kills on this node only … does not affect other nodes".
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.72. Build tag → bgpoll-v5.

- 2026-06-15: FIX — oracle-proxy.py 3.20.71: cancel verified kill + honest reporting.
  apps_job_cancel resolves target pgid: /pgid file → getpgid(script_pid) → getpgid(su_pid).
  SIGTERM → 3s → SIGKILL → 1s. Verifies via os.kill(script_pid,0). Escalates if still
  alive: pkill -KILL children → SIGKILL group → re-verify. Only writes TV_DONE rc=130 +
  returns {cancelled:true,killed:true} if process confirmed dead; returns
  {cancelled:false,still_running:true} without TV_DONE if it survives. routes/ebs-ops.js
  /api/ebs-ops/cancel: captures proxy verdict, only calls cancelJob() on confirmed kill,
  returns {cancelled:false,still_running:true} otherwise. ebs-ops.html: commitCancelJob
  now awaits the response; confirmed → stops poll + shows Cancelled; still_running →
  keeps poll running + shows pkill command in confirm box + ↻ Retry Cancel button.
  retryForceCancel() skips the CANCEL input check for retry path. Build → bgpoll-v4.
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.71.

- 2026-06-15: FIX — oracle-proxy.py 3.20.70: cancel kills correct process group.
  _launch_bg_job and apps_stop/start_all_bg inline path now inject `echo $$ >
  .../script_pid` + `ps -o pgid= -p $$ > .../pgid` after shebang in every bg
  script. apps_job_cancel rewrites: prefers pgid file (bash script's own group)
  over su-wrapper PID; SIGTERM → 3s → alive-check → SIGKILL; fallback to su-pid
  when pgid not yet written. routes/ebs-ops.js: 409 mutex guard before all
  *_start/*_stop ops (getActiveJobForConnection); status reads pass through.
  ebs-ops.html: _setOpLocks(locked) helper; locks on _pollBgJob start, unlocks
  at every terminal path + commitCancelJob; 409 handled in runMiddlewareOp
  (error panel, finally re-enables btn) and runMiddlewareOpWithServerName
  (error panel, returns 'failed' to break startAllServersForCard loop).
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.70.

- 2026-06-15: FEAT — oracle-proxy.py 3.20.69: Part A + Part B: bg promotion +
  Cancel button. New bg ops: adcmctl_start/stop_bg, wls_admin_start/stop_bg,
  managed_server_start/stop_bg — all use shared _launch_bg_job() helper + same
  mkstemp/Popen pattern as apps_start/stop_all_bg. apps_job_cancel new op:
  SIGTERM→wait 3s→SIGKILL the process group, appends TV_DONE rc=130 to log.
  apps_job_status: rc=130 → state='cancelled'. LONG_OPS set in ebs-ops.js now
  includes all 8 long ops (was only 2). POST /api/ebs-ops/cancel endpoint.
  ebs-ops.html: _pollBgJob() shared poller with inline ⏹ Cancel button + typed
  CANCEL confirmation gate; runMiddlewareOpWithServerName gains parentOpKey param
  (result renders in parent status card panel). config/proxy-version.js
  LATEST_PROXY_VERSION → 3.20.69.

- 2026-06-15: FIX — ebs-ops.html: correct cancel button label + refreshAllServiceStatus feedback.
  Extracted _restoreOpButton(btnEl, opKey) helper: FULLSTACK_OPS get icon+label, ctrl ops get
  '▶ Status', others '▶ Run'. commitCancelJob + _pollBgJob terminal block both call it so
  apps_start/stop_all restore "▶ Start/Stop All App Services" (was always '▶ Status').
  refreshAllServiceStatus(clickedBtn) accepts the clicked button, shows spinner + "Refreshing…",
  then "✓ Refreshed" for 2s before restoring label. All 9 status ops run in parallel via
  Promise.all. Scrolls to result-mw-adapcctl_status on completion.

- 2026-06-15: FIX — ebs-ops.html: bg-op auto-refresh, Check Services Now, sequential server loop.
  Fix A: _pollBgJob success branch auto-refreshes service status for fullstack ops — stop_all
  after 3s; start_all after 25s with live countdown note. Fix B: storeAndRenderMiddlewareResult
  appends ↻ Check Services Now button into result-body (omitted for fullstack ops with auto-refresh).
  Fix C: _pollBgJob returns Promise resolving to terminal state string via _done() guard;
  startAllServersForCard + commitStopAllServersForCard await state and break loop on
  'cancelled'/'failed' — truly sequential per-server start/stop with one active Cancel at a time.

- 2026-06-15: FIX — ebs-ops.html: _pollBgJob builds cancel panel once — subsequent ticks update
  leaf nodes only. Per-tick panel.innerHTML = ... was re-creating cancel-confirm-<jobId> as
  display:none and wiping cancel-inp-<jobId> every 5s, making typed-CANCEL impossible.
  Fix: first running tick builds full skeleton with stable IDs (bgjob-elapsed-<jobId>,
  bgjob-log-<jobId>, cancel-confirm-<jobId>); detected by presence of bgjob-elapsed-<jobId>.
  Subsequent ticks only update leaf nodes via textContent; if cancel-confirm is visible, skip
  even that. Terminal states still do full innerHTML replace (interval already cleared).

- 2026-06-15: FIX — oracle-proxy.py 3.20.68: bg job script via mkstemp /tmp — aligns with
  proven sync path. apps_stop/start_all_bg no longer write script into /tmp/tv_ebs_jobs/<id>/
  run.sh. Instead created via tempfile.mkstemp(prefix=".tv_ebsjob_", suffix=".sh", dir="/tmp"),
  chmod 0o755, chowned to apps_user — matching the proven sync .tv_ctrl_*.sh path that works
  with PrivateTmp. Eliminates 700-mode chowned-subdir race where chown/mode sequencing left
  run.sh inaccessible to the su'd bash. Job subdir still exists for log + pid files.
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.68.

- 2026-06-15: FIX — oracle-proxy.py 3.20.67: run.sh self-deletes on launch; card-level Start/Stop
  for managed servers; no auto-status. Fix 1: run.sh now has `rm -f "$0"` as first bash instruction
  before sourcing EBSapps.env — bash holds inode open so execution continues; credentials wiped
  from disk immediately (old os.unlink ran before su's child bash could open it → "No such file
  or directory"). Fix 2: OACore/Forms/OAFM/All Managed Servers cards now show ▶ Status + ▶ Start
  + ▶ Stop at card level; Start loops managed_server_start over all servers from last Status
  parse; Stop uses typed-CONFIRM gate then loops managed_server_stop; server names cached in
  _perSrvNames. Fix 3: removed all automatic post-control status refreshes; status is manual-only.
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.67.

- 2026-06-15: FIX — oracle-proxy.py 3.20.66: status checks require positive up-signal, not exit 0.
  EBS admin scripts (adapcctl, adopmnctl, adadminsrvctl, adnodemgrctl) exit 0 even when the service
  is DOWN. All *_status ops now require: exit 0 AND positive up text AND no down-indicator.
  Down-indicator set: "is not running", "not running", "is not up", "is shutdown", "no listener",
  "tns-12541", "connection refused". adalnctl adds _has_down guard to existing STATUS-block check.
  ebs-ops.html: removed `else if (exit===0) { state='RUNNING' }` fallback in parseServerBlocks —
  unknown output now classifies as UNKNOWN. 500-error detail renders `data.detail + ': ' + data.error`.
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.66.

- 2026-06-15: FEAT — oracle-proxy.py 3.20.65: detached Stop/Start All — bg subprocess + server-side
  poller. Root cause: apps_start_all ran adstrtal.sh synchronously inside proxy HTTP handler; agent
  channel timed out at 25s. Fix: apps_stop/start_all_bg ops write run.sh to /tmp/tv_ebs_jobs/<id>/,
  launch fully detached via subprocess.Popen(start_new_session=True), unlink run.sh, return
  {started, pid} within ~2s. adstrtal timeout raised to 5400s (90 min). apps_job_status reads log,
  parses TV_DONE rc=N, kill -0 pid check → {state, rc, elapsed_sec, log_tail}.
  routes/ebs-ops.js: LONG_OPS calls _bg ops (15s channel timeout), waits {started:true}, calls
  startJob, returns 202. Server-side poller (setInterval 15s) queries running ebs_jobs, sends
  apps_job_status per agent, calls completeJob on done/failed/unknown. GET /api/ebs-ops/jobs/:id.
  routes/agent.js: removed setImmediate completeJob-on-respond (would prematurely close bg jobs).
  db/ebs-jobs.js: getRunningJobs(), updateJobLogTail(). server.js: log_tail TEXT column on ebs_jobs.
  ebs-ops.html: running state shows spinner + elapsed + live log tail; unknown/timeout show ⚠.
  config/proxy-version.js LATEST_PROXY_VERSION → 3.20.65.

- 2026-06-15: FIX — LATEST_PROXY_VERSION unified to single source of truth in
  config/proxy-version.js (exports LATEST_PROXY_VERSION = '3.20.68' and versionLessThan).
  server.js, routes/agent.js, routes/connections-list.js now import from there; stale
  hardcoded '3.20.63' copies in agent.js and connections-list.js removed. These were
  blocking auto-upgrade for conn 140 (and any proxy running 3.20.64–3.20.67) because
  the versionLessThan gate in agent.js compared against 3.20.63 and always returned false.
  server.js raw-string `<` comparison also replaced with versionLessThan() for correctness.

- 2026-06-14: FIX — oracle-proxy.py 3.20.64: apps_start_all subprocess timeout 1800→3600s
  (60 min) — adstrtal.sh was being killed at 30 min on slower EBS instances before all
  services came up. ebs-jobs.js watchdog extended 45→75 min to match. LATEST_PROXY_VERSION
  3.20.64. Branch (c): ebs_jobs delivery confirmed working (rowCount=1); cause was timeout.
- 2026-06-14: FIX — server.js ensureColumns(): ebs_jobs table no longer dropped on every
  boot. Replaced DROP TABLE + CREATE TABLE with CREATE TABLE IF NOT EXISTS + additive
  ALTER TABLE … ADD COLUMN IF NOT EXISTS guards so in-flight stop/start jobs survive
  Render auto-deploys. completeJob() now returns rowCount; agent.js logs it so the
  first failed run shows whether the UPDATE lands (rowCount=0 means row was wiped).
- 2026-06-13: FIX — oracle-proxy.py 3.20.63: /respond result submission retries up to
  3× with 10s delay on network errors (BrokenPipeError, ConnectionRefusedError). Guards
  against Render deploy-restart killing the in-flight result POST. LATEST_PROXY_VERSION
  3.20.63.
- 2026-06-12: chore — oracle-proxy.py 3.20.62: version bump to trigger agent pickup.
- 2026-06-12: chore — oracle-proxy.py 3.20.60: version bump to trigger agent pickup of
  wording/delay/reorder changes shipped under 3.20.59.
- 2026-06-12: FIX — oracle-proxy.py 3.20.61: apps_start_all proxy timeout 1225→1800s (30 min);
  route 1820000ms; frontend 1825000ms. ebs-ops.html: full-stack timeout no longer shows ✗ error —
  detected via "Request timed out" prefix on storeKey apps_stop/start_all, renders "⚠ Still running"
  badge + guidance text + "↻ Check Services Now" button (calls extracted refreshAllServiceStatus()).
  LATEST_PROXY_VERSION 3.20.61.
- 2026-06-12: FIX — oracle-proxy.py 3.20.59: apps_stop_all adds sleep 20 + TV_PROGRESS before
  OS process check (grace period for processes to exit); apps_start_all adds sleep 15.
  Stop op grep chain gains `| grep -v "RemoteCommand.*kill"` to exclude JTF shutdown process.
  Timeouts: stop 600→630s, start 1200→1225s; routes 650k/1245k ms; frontend 655k/1250k ms.
  ebs-ops.html renderMiddlewareTab: Full Stack Control moved to top of Service Status tab;
  Individual Services sub-header added; Advanced Operations stays at bottom.
  LATEST_PROXY_VERSION 3.20.59.
- 2026-06-12: FEAT — oracle-proxy.py 3.20.58: apps_stop_all/apps_start_all append OS process
  verification block after adstpall/adstrtal — ps filtered for httpd/java/tnslsnr/FNDLIBR/
  FNDSM/opmn/frmweb/f60; "none found" message on clean stop. ebs-ops.html: card descs
  corrected (adstpall.sh y → -mode=allnodes); warning banner updated; storeAndRenderMiddlewareResult
  parses OS_PROCESS_CHECK sentinel and renders "🔍 OS Process Verification" sub-header with
  monospace pre block. LATEST_PROXY_VERSION 3.20.58.
- 2026-06-12: FIX — oracle-proxy.py 3.20.57: apps_stop_all/apps_start_all timeouts doubled
  (stop: 300→600s, start: 600→1200s). TV_PROGRESS echo markers added to bash script so
  result panel shows step summary (start, success/timeout/exit code) after the op completes.
  routes/ebs-ops.js ctrlTimeout: 310→620s / 610→1220s. ebs-ops.html: _MW_TIMEOUTS 315→625s /
  615→1225s; runMiddlewareOp gains 5s setInterval elapsed counter for full-stack ops;
  storeAndRenderMiddlewareResult parses TV_PROGRESS lines and renders them prominently before
  raw stdout. report.html build tag updated to 2026-06-11-ebs-ops-v1.
  LATEST_PROXY_VERSION 3.20.57.
- 2026-06-11: FIX — oracle-proxy.py 3.20.56: "Workflow Mailer Not Running" finding
  remediation text corrected — old text pointed to "EBS System Admin → Workflow →
  Service Components" with no detail; new text lists 3 options: TuneVault EBS Ops
  WF Mailer tab, OAM UI path, and PL/SQL fnd_svc_component.start_component snippet.
  Explicitly notes adadminsrvctl.sh start wfmail does not exist.
  LATEST_PROXY_VERSION 3.20.56.
- 2026-06-11: FIX — oracle-proxy.py 3.20.55: adstpall/adstrtal command corrected —
  APPS password is now a slash-joined argument ("APPS/$APPS_PWD"), WLS password
  piped via stdin only, -mode=allnodes added for multi-node support. ebs-ops.html:
  Full Stack Control cards now use mw- element IDs so storeAndRenderMiddlewareResult
  and runMiddlewareOp render the result panel correctly; _MW_TIMEOUTS entries added.
  LATEST_PROXY_VERSION 3.20.55.
- 2026-06-11: FEAT — oracle-proxy.py 3.20.54: apps_start_all/apps_stop_all ops
  added to /api/ebs-ctrl whitelist. Run adstrtal.sh y / adstpall.sh y via agent
  tunnel; passwords piped non-interactively via printf. Timeout: 610s start,
  310s stop. routes/ebs-ops.js: catalog + ctrlTimeoutMap updated. ebs-ops.html:
  new "Full Stack Control" section at bottom of Service Status tab — two cards
  with typed confirmation (START-ALL-APPS / STOP-ALL-APPS) and auto-refresh of
  all individual service status cards on success. LATEST_PROXY_VERSION 3.20.54.
- 2026-06-11: FIX — oracle-proxy.py 3.20.53: c4ws/oaea managed servers
  (forms-c4ws_server1, oaea_server1) classified as warning when down, not
  critical — these are optional EBS components not deployed on all instances.
  LATEST_PROXY_VERSION bumped to 3.20.53.
- 2026-06-11: FIX — report.html: EBS App Tier export buttons now inject as
  soon as report renders (status=analyzing + server_type=apps); explicit
  injectExportButtons() fallback added on poll completion.
- 2026-06-10: docs — rewrote all EBS Ops card descriptions to plain English
- 2026-06-10: FEAT — oracle-proxy.py 3.20.52: fnd_svc_ctrl_start/stop ops 
  (start/stop any GSM component by component_id); wf_mailer_reset pre-checks 
  DEACTIVATED_SYSTEM and shows advisory instead of silently failing. 
  ebs-ops.html: WF Mailer Status table shows per-component Start/Stop/⚠ 
  inline buttons; fixed column order (COMPONENT_NAME first), colored status 
  badges, 60-char truncation on COMPONENT_STATUS_INFO.
- 2026-06-10: FIX — oracle-proxy.py 3.20.51: wf_mailer_start/stop/reset all 
  use correct fnd_svc_component PL/SQL signature with named params 
  (p_component_id, p_retcode OUT, p_errbuf OUT). DEACTIVATED_SYSTEM reset 
  uses UPDATE component_status='STOPPED', component_status_info=NULL 
  (restart_count column does not exist on EBS 12.2).
- 2026-06-10: FEAT — oracle-proxy.py 3.20.50: all three WF Mailer control 
  ops (start/stop/reset) use fnd_svc_component PL/SQL only — no adcmctl. 
  Scoped to WF Mailer component only, does not affect other CMs.
- 2026-06-10: FEAT — oracle-proxy.py 3.20.48/49: CM Status card shows 
  FNDLIBR/FNDSM process list (CM_PROCESSES block) after adcmctl status. 
  WF Mailer Start/Stop use targeted fnd_svc_component PL/SQL. CM control 
  cards (adcmctl) correctly documented as affecting ALL CMs + GSM.
- 2026-06-10: FEAT — oracle-proxy.py 3.20.41: full start/stop controls for 
  all service components — adapcctl/adopmnctl/adalnctl/adnodemgrctl/
  wls_admin start+stop; managed_server_start/stop (per-server, requires 
  server_name validated ^[a-zA-Z0-9_-]{1,64}$); adcmctl_start/stop. 
  WF Mailer tab added (5th tab): PL/SQL stop/start/reset + 7 SQL cards. 
  CONFIRM gate on all destructive ops; post-control auto-refresh.
- 2026-06-10: FIX — oracle-proxy.py 3.20.47: adalnctl_status rewritten to 
  use TCP lsnrctl directly (bypasses PrivateTmp Unix socket isolation). 
  Port read from listener.ora dynamically. ok = exit==0 AND "STATUS of 
  the LISTENER" in stdout. Fixes persistent TNS-12541 false-down.
- 2026-06-10: FIX — oracle-proxy.py 3.20.44: hardcoded APPS_EBSDB removed 
  from pgrep; replaced with $TWO_TASK-based dynamic listener name. 
  ready-to-test.js lab SSH fallback nulled.
- 2026-06-10: FIX — oracle-proxy.py 3.20.45: unset ORACLE_HOME/TNS_ADMIN/
  LD_LIBRARY_PATH before sourcing EBSapps.env in all generated scripts — 
  prevents proxy's Oracle instant-client env leaking into EBS scripts.
- 2026-06-10: FIX — oracle-proxy.py 3.20.42: adcmctl.sh credential format 
  fixed to "apps/$APPS_PWD" (single slash-joined arg, not two args). 
  ADOP/patch queries fixed for EBS 12.2 schema (no APPLIED_ON_NODE; 
  use AD_ADOP_SESSION_PATCHES). WF Mailer query fixed (no LAST_ERROR_DATE; 
  use COMPONENT_STATUS_INFO). WF Errors query uses ASSIGNED_DATE not 
  ERROR_DATE. Managed server state classified from output text (NOT_DEPLOYED,
  RUNNING, DOWN, optional c4ws/oaea).
- 2026-06-10: FEAT — oracle-proxy.py 3.20.40: per-server managed server 
  start/stop added. Server name validated in both proxy and Node route.
  Service Status tab: per-server Start/Stop buttons render inside result 
  panels after status runs.
- 2026-06-09: FEAT — DB Ops and EBS Ops inline collapsible results matching 
  pattern across all cards. result-panel / result-panel-hdr / result-toggle.
- 2026-06-09: FIX — PDF export: all generators fixed (doc.end before pipe 
  was hanging HTTP response). HC completion email: f.detail → f.details.
- 2026-06-09: FIX — DB Ops Run endpoint: removed stale cx_oracle_version 
  pre-check that blocked runs when field was null.
