diff --git a/elixir/SKILL.md b/elixir/SKILL.md
index 4bb390e..388fad5 100644
--- a/elixir/SKILL.md
+++ b/elixir/SKILL.md
@@ -6,6 +6,7 @@
|------|-------|
| Current Version | **Elixir 1.19.5** (target for all new code) |
| Required OTP | **OTP 27+** (OTP 28 supported) |
+| Phoenix Version | **1.8.5** (latest — requires `:formats` on controllers) |
| Cortex Status | Elixir **not yet installed** on cortex.hydrascale.net |
| AI Agent Tooling | `usage_rules` hex package (~> 1.2) — **always include** |
| Production Framework | **Ash Framework** for substantial projects |
@@ -655,11 +656,612 @@ end
---
+## Phoenix Framework — v1.8.5 (Current)
+
+Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.
+
+### What's New in Phoenix 1.8
+
+- **Scopes** in generators — secure data access by default (e.g., `current_user` automatically applied)
+- **Magic links** (passwordless auth) and **"sudo mode"** in `mix phx.gen.auth`
+- **daisyUI** integration — light/dark/system mode support out of the box
+- **Simplified layouts** — single `root.html.heex` wraps everything; dynamic layouts are function components
+- **`use Phoenix.Controller` now requires `:formats`** — must specify `formats: [:html]` or `formats: [:html, :json]`
+- **Updated security headers** — `content-security-policy` with `base-uri 'self'; frame-ancestors 'self'`; dropped deprecated `x-frame-options` and `x-download-options`
+- **`config` variable removed** from `Phoenix.Endpoint` — use `Application.compile_env/3` instead
+- **Deprecated**: `:namespace`, `:put_default_views`, layouts without modules, `:trailing_slash` in router
+
+### Project Setup
+
+```bash
+# New project (Phoenix Express — quickest path)
+curl https://new.phoenixframework.org/my_app | sh
+
+# Traditional setup
+mix phx.new my_app
+mix phx.new my_app --no-ecto # Without database
+mix phx.new my_app --no-html # API only
+mix phx.new my_app --database sqlite3 # SQLite instead of Postgres
+```
+
+### Directory Structure
+
+```
+my_app/
+├── lib/
+│ ├── my_app/ # Business logic (contexts, schemas)
+│ │ ├── application.ex # Supervision tree
+│ │ ├── repo.ex # Ecto Repo
+│ │ └── catalog/ # Context: Catalog
+│ │ ├── product.ex # Schema
+│ │ └── catalog.ex # Context module
+│ └── my_app_web/ # Web layer
+│ ├── endpoint.ex # HTTP entry point
+│ ├── router.ex # Routes + pipelines
+│ ├── components/
+│ │ ├── core_components.ex # Shared UI components
+│ │ └── layouts.ex # Layout components
+│ ├── controllers/
+│ │ ├── page_controller.ex
+│ │ └── page_html/ # Templates for controller
+│ │ └── home.html.heex
+│ └── live/ # LiveView modules
+│ └── counter_live.ex
+├── config/
+│ ├── config.exs # Compile-time config
+│ ├── dev.exs / prod.exs
+│ └── runtime.exs # Runtime config (env vars, secrets)
+├── priv/
+│ ├── repo/migrations/
+│ └── static/ # Static assets
+├── test/
+│ ├── support/
+│ │ ├── conn_case.ex # Test helpers for controllers
+│ │ └── data_case.ex # Test helpers for data layer
+│ └── my_app_web/
+└── mix.exs
+```
+
+### Router & Pipelines
+
+```elixir
+defmodule MyAppWeb.Router do
+ use MyAppWeb, :router
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
+ scope "/", MyAppWeb do
+ pipe_through :browser
+
+ get "/", PageController, :home
+ live "/counter", CounterLive # LiveView route
+ resources "/products", ProductController # RESTful CRUD
+ end
+
+ scope "/api", MyAppWeb do
+ pipe_through :api
+
+ resources "/items", ItemController, except: [:new, :edit]
+ end
+end
+```
+
+### Verified Routes — `~p` Sigil
+
+**Always use `~p` instead of string paths.** Compile-time verified against your router.
+
+```elixir
+# In templates (HEEx)
+~H"""
+View
+Edit
+"""
+
+# In controllers
+redirect(conn, to: ~p"/products/#{product}")
+
+# With query params
+~p"/products?page=#{page}&sort=name"
+~p"/products?#{%{page: 1, sort: "name"}}"
+
+# URL generation (includes host)
+url(~p"/products/#{product}")
+# => "https://example.com/products/42"
+```
+
+### Controllers (1.8 Style)
+
+```elixir
+defmodule MyAppWeb.ProductController do
+ use MyAppWeb, :controller
+
+ # REQUIRED in 1.8: specify formats
+ # This is typically set in MyAppWeb :controller function
+
+ def index(conn, _params) do
+ products = Catalog.list_products()
+ render(conn, :index, products: products)
+ end
+
+ def show(conn, %{"id" => id}) do
+ product = Catalog.get_product!(id)
+ render(conn, :show, product: product)
+ end
+
+ def create(conn, %{"product" => product_params}) do
+ case Catalog.create_product(product_params) do
+ {:ok, product} ->
+ conn
+ |> put_flash(:info, "Product created.")
+ |> redirect(to: ~p"/products/#{product}")
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, :new, changeset: changeset)
+ end
+ end
+end
+```
+
+**View module naming:** For `ProductController`, Phoenix looks for `ProductHTML` (for `:html` format) and `ProductJSON` (for `:json` format).
+
+```elixir
+# lib/my_app_web/controllers/product_html.ex
+defmodule MyAppWeb.ProductHTML do
+ use MyAppWeb, :html
+ embed_templates "product_html/*" # Loads .heex files from directory
+end
+
+# lib/my_app_web/controllers/product_json.ex
+defmodule MyAppWeb.ProductJSON do
+ def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
+ def show(%{product: product}), do: %{data: data(product)}
+ defp data(product), do: %{id: product.id, title: product.title, price: product.price}
+end
+```
+
+### Components and HEEx
+
+```elixir
+defmodule MyAppWeb.CoreComponents do
+ use Phoenix.Component
+
+ # Declare attributes with types and docs
+ attr :type, :string, default: "button"
+ attr :class, :string, default: nil
+ attr :rest, :global # Passes through all other HTML attrs
+ slot :inner_block, required: true
+
+ def button(assigns) do
+ ~H"""
+
+ """
+ end
+
+ # Table component with slots
+ attr :rows, :list, required: true
+ slot :col, required: true do
+ attr :label, :string
+ end
+
+ def table(assigns) do
+ ~H"""
+
+
+
+ | {col[:label]} |
+
+
+
+
+ | {render_slot(col, row)} |
+
+
+
+ """
+ end
+end
+```
+
+**HEEx syntax notes:**
+- `{@var}` — render assign (curly braces, not `<%= %>`)
+- `:if={condition}` — conditional rendering on any tag
+- `:for={item <- list}` — iteration on any tag
+- `<.component_name />` — call function component with dot notation
+- `` — call remote component with module name
+- `{render_slot(@inner_block)}` — render slot content
+- `<:slot_name>content` — named slot content
+
+### LiveView
+
+```elixir
+defmodule MyAppWeb.SearchLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, query: "", results: [])}
+ end
+
+ def handle_params(%{"q" => query}, _uri, socket) do
+ {:noreply, assign(socket, query: query, results: search(query))}
+ end
+
+ def handle_params(_params, _uri, socket), do: {:noreply, socket}
+
+ def handle_event("search", %{"query" => query}, socket) do
+ {:noreply,
+ socket
+ |> assign(query: query, results: search(query))
+ |> push_patch(to: ~p"/search?q=#{query}")}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
{result.title}
+
{result.summary}
+
+
+ """
+ end
+
+ defp search(query), do: MyApp.Search.find(query)
+end
+```
+
+**LiveView lifecycle:** `mount/3` → `handle_params/3` → `render/1`. Events via `handle_event/3`. Server pushes via `handle_info/2`.
+
+**Key patterns:**
+- `assign/2,3` — set socket assigns
+- `push_navigate/2` — navigate to new LiveView (full mount)
+- `push_patch/2` — update URL without full remount (calls `handle_params`)
+- `push_event/3` — push event to client JS hooks
+- `stream/3,4` — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
+- `async_assign/3` + `assign_async/3` — async data loading with loading/error states
+
+### Channels — Real-Time WebSocket
+
+```elixir
+# In endpoint.ex
+socket "/socket", MyAppWeb.UserSocket,
+ websocket: true,
+ longpoll: false
+
+# lib/my_app_web/channels/user_socket.ex
+defmodule MyAppWeb.UserSocket do
+ use Phoenix.Socket
+
+ channel "room:*", MyAppWeb.RoomChannel
+
+ def connect(%{"token" => token}, socket, _connect_info) do
+ case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
+ {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
+ {:error, _} -> :error
+ end
+ end
+
+ def id(socket), do: "users_socket:#{socket.assigns.user_id}"
+end
+
+# lib/my_app_web/channels/room_channel.ex
+defmodule MyAppWeb.RoomChannel do
+ use MyAppWeb, :channel
+
+ def join("room:" <> room_id, _params, socket) do
+ {:ok, assign(socket, :room_id, room_id)}
+ end
+
+ def handle_in("new_msg", %{"body" => body}, socket) do
+ broadcast!(socket, "new_msg", %{
+ body: body,
+ user_id: socket.assigns.user_id
+ })
+ {:noreply, socket}
+ end
+end
+```
+
+**Force disconnect all sessions for a user:**
+```elixir
+MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
+```
+
+### Contexts — Business Logic Boundary
+
+Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.
+
+```elixir
+# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
+defmodule MyApp.Catalog do
+ import Ecto.Query
+ alias MyApp.Repo
+ alias MyApp.Catalog.Product
+
+ def list_products do
+ Repo.all(Product)
+ end
+
+ def get_product!(id), do: Repo.get!(Product, id)
+
+ def create_product(attrs \\ %{}) do
+ %Product{}
+ |> Product.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_product(%Product{} = product, attrs) do
+ product
+ |> Product.changeset(attrs)
+ |> Repo.update()
+ end
+
+ def delete_product(%Product{} = product) do
+ Repo.delete(product)
+ end
+
+ def change_product(%Product{} = product, attrs \\ %{}) do
+ Product.changeset(product, attrs)
+ end
+end
+```
+
+**Context design principles:**
+- One context per bounded domain (Catalog, Accounts, Orders)
+- Contexts own their schemas — other contexts reference by ID, not struct
+- Cross-context calls go through the public context API, never access another context's Repo directly
+- Contexts can nest related schemas (Comments under Posts)
+
+### Authentication — `mix phx.gen.auth`
+
+```bash
+mix phx.gen.auth Accounts User users
+```
+
+Phoenix 1.8 generates:
+- **Magic links** (passwordless) — email-based login links
+- **"Sudo mode"** — re-authentication for sensitive actions
+- Session-based auth with secure token handling
+- Email confirmation and password reset flows
+- `require_authenticated_user` plug for protected routes
+
+### Scopes (New in 1.8)
+
+Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.
+
+```bash
+mix phx.gen.live Posts Post posts title body:text --scope current_user
+```
+
+This generates code that automatically passes `current_user` to context functions, ensuring users only see their own data.
+
+### Ecto — Database Layer
+
+```elixir
+# Schema
+defmodule MyApp.Catalog.Product do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "products" do
+ field :title, :string
+ field :price, :decimal
+ field :status, Ecto.Enum, values: [:draft, :published, :archived]
+ has_many :reviews, MyApp.Reviews.Review
+ belongs_to :category, MyApp.Catalog.Category
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(product, attrs) do
+ product
+ |> cast(attrs, [:title, :price, :status, :category_id])
+ |> validate_required([:title, :price])
+ |> validate_number(:price, greater_than: 0)
+ |> unique_constraint(:title)
+ |> foreign_key_constraint(:category_id)
+ end
+end
+
+# Queries
+import Ecto.Query
+
+# Composable queries
+def published(query \\ Product) do
+ from p in query, where: p.status == :published
+end
+
+def recent(query, days \\ 7) do
+ from p in query, where: p.inserted_at > ago(^days, "day")
+end
+
+def with_reviews(query) do
+ from p in query, preload: [:reviews]
+end
+
+# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()
+```
+
+### Telemetry — Built-in Observability
+
+Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.
+
+```elixir
+# lib/my_app_web/telemetry.ex (auto-generated)
+defmodule MyAppWeb.Telemetry do
+ use Supervisor
+ import Telemetry.Metrics
+
+ def metrics do
+ [
+ summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
+ summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
+ summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ ]
+ end
+end
+```
+
+Integrates with **PromEx** for Prometheus/Grafana dashboards (see Monitoring section).
+
+### Phoenix Security Best Practices
+
+**Never pass untrusted input to:** `Code.eval_string/3`, `:os.cmd/2`, `System.cmd/3`, `System.shell/2`, `:erlang.binary_to_term/2`
+
+**Ecto prevents SQL injection by default** — the query DSL parameterizes all inputs. Only `Ecto.Adapters.SQL.query/4` with raw string interpolation is vulnerable.
+
+**Safe deserialization:**
+```elixir
+# UNSAFE — even with :safe flag
+:erlang.binary_to_term(user_input, [:safe])
+
+# SAFE — prevents executable terms
+Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])
+```
+
+**CSRF protection** is built into the `:browser` pipeline via `protect_from_forgery`. **Content Security Policy** is set by `put_secure_browser_headers`.
+
+### Testing Phoenix
+
+```elixir
+# Controller test
+defmodule MyAppWeb.ProductControllerTest do
+ use MyAppWeb.ConnCase
+
+ test "GET /products", %{conn: conn} do
+ conn = get(conn, ~p"/products")
+ assert html_response(conn, 200) =~ "Products"
+ end
+
+ test "POST /products creates product", %{conn: conn} do
+ conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
+ assert redirected_to(conn) =~ ~p"/products/"
+ end
+end
+
+# LiveView test
+defmodule MyAppWeb.CounterLiveTest do
+ use MyAppWeb.ConnCase
+ import Phoenix.LiveViewTest
+
+ test "increments counter", %{conn: conn} do
+ {:ok, view, html} = live(conn, ~p"/counter")
+ assert html =~ "Count: 0"
+
+ assert view
+ |> element("button", "+1")
+ |> render_click() =~ "Count: 1"
+ end
+end
+
+# Channel test
+defmodule MyAppWeb.RoomChannelTest do
+ use MyAppWeb.ChannelCase
+
+ test "broadcasts new messages" do
+ {:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")
+
+ push(socket, "new_msg", %{"body" => "hello"})
+ assert_broadcast "new_msg", %{body: "hello"}
+ end
+end
+```
+
+### Phoenix Generators Cheat Sheet
+
+```bash
+# HTML CRUD (controller + views + templates + context + schema + migration)
+mix phx.gen.html Catalog Product products title:string price:decimal
+
+# LiveView CRUD
+mix phx.gen.live Catalog Product products title:string price:decimal
+
+# JSON API
+mix phx.gen.json Catalog Product products title:string price:decimal
+
+# Context + schema only (no web layer)
+mix phx.gen.context Catalog Product products title:string price:decimal
+
+# Schema + migration only
+mix phx.gen.schema Product products title:string price:decimal
+
+# Authentication
+mix phx.gen.auth Accounts User users
+
+# Channel
+mix phx.gen.channel Room
+
+# Presence
+mix phx.gen.presence
+
+# Release files (Dockerfile, release.ex, overlay scripts)
+mix phx.gen.release
+mix phx.gen.release --docker # Include Dockerfile
+```
+
+### Phoenix Release & Deployment
+
+```bash
+# Generate release infrastructure
+mix phx.gen.release --docker
+
+# Build release
+MIX_ENV=prod mix deps.get --only prod
+MIX_ENV=prod mix compile
+MIX_ENV=prod mix assets.deploy
+MIX_ENV=prod mix release
+
+# Release commands
+_build/prod/rel/my_app/bin/server # Start with Phoenix server
+_build/prod/rel/my_app/bin/migrate # Run migrations
+_build/prod/rel/my_app/bin/my_app remote # Attach IEx console
+```
+
+**Runtime config (`config/runtime.exs`):**
+```elixir
+import Config
+
+if config_env() == :prod do
+ database_url = System.fetch_env!("DATABASE_URL")
+ secret_key_base = System.fetch_env!("SECRET_KEY_BASE")
+
+ config :my_app, MyApp.Repo,
+ url: database_url,
+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
+
+ config :my_app, MyAppWeb.Endpoint,
+ url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
+ http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
+ secret_key_base: secret_key_base
+end
+```
+
+---
+
## Common Libraries
| Library | Purpose | Hex |
|---------|---------|-----|
-| Phoenix | Web framework + LiveView | `{:phoenix, "~> 1.7"}` |
+| Phoenix | Web framework + LiveView | `{:phoenix, "~> 1.8"}` |
| Ecto | Database wrapper + query DSL | `{:ecto_sql, "~> 3.12"}` |
| Ash | Declarative resource framework | `{:ash, "~> 3.0"}` |
| Oban | Background job processing | `{:oban, "~> 2.18"}` |