This is a web application written using the Phoenix web framework.
- Design principle: BSSN (Best Simple System for Now). Prioritise the simplest design that meets today’s needs to appropriate standards. Avoid speculative complexity; defer features until needed. Main focus is processing speed.
Epona Specs Overview
Epona is a RETE-based rule engine implemented in Elixir, powering payroll, compliance, and wage cost estimation use cases. Start with the overview, then dive into architecture and orchestration when implementing features.
- Overview: specs/rete_overview.md — RETE concepts, Alpha/Beta networks, agenda, refraction
- Architecture: specs/architecture.md — engine components, data model, network representation
- Orchestrator: specs/orchestrator.md — per-connection engines, partitions, supervision topology
- Public API: specs/api.md — external functions and boundaries for ingest and control
- DSL: specs/dsl.md — rule definition primitives and compilation notes
- Temporal Semantics: specs/temporal_semantics.md — effective time, ranges, and filtering
- Performance & Sizing: specs/performance.md, specs/sizing_guide.md — targets and capacity planning
- Phoenix Integration: specs/integration_phoenix.md — how Epona embeds in the app
Project guidelines
- Use
mix precommitalias when you are done with all changes and fix any pending issues - Use the already included and available
:req(Req) library for HTTP requests, avoid:httpoison,:tesla, and:httpc. Req is included by default and is the preferred HTTP client for Phoenix apps
Phoenix v1.8 guidelines
- Always begin your LiveView templates with
<Layouts.app flash={@flash} ...>which wraps all inner content - The
MyAppWeb.Layoutsmodule is aliased in themy_app_web.exfile, so you can use it without needing to alias it again - Anytime you run into errors with no
current_scopeassign:- You failed to follow the Authenticated Routes guidelines, or you failed to pass
current_scopeto<Layouts.app> - Always fix the
current_scopeerror by moving your routes to the properlive_sessionand ensure you passcurrent_scopeas needed
- You failed to follow the Authenticated Routes guidelines, or you failed to pass
- Phoenix v1.8 moved the
<.flash_group>component to theLayoutsmodule. You are forbidden from calling<.flash_group>outside of thelayouts.exmodule - Out of the box,
core_components.eximports an<.icon name="hero-x-mark" class="w-5 h-5"/>component for for hero icons. Always use the<.icon>component for icons, never useHeroiconsmodules or similar - Always use the imported
<.input>component for form inputs fromcore_components.exwhen available.<.input>is imported and using it will will save steps and prevent errors - If you override the default input classes (
<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input
Elixir guidelines
- Elixir lists do not support index based access via the access syntax
Never do this (invalid):
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, always use
Enum.at, pattern matching, orListfor index based list access, ie: i = 0 mylist = ["blue", "green"] Enum.at(mylist, i) - Elixir variables are immutable, but can be rebound, so for block expressions like
if,case,cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: # INVALID: we are rebinding inside theifand the result never gets assigned if connected?(socket) do socket = assign(socket, :val, val) end # VALID: we rebind the result of theifto a new variable socket = if connected?(socket) do assign(socket, :val, val) end - Avoid nesting multiple modules in one file unless there is a clear, cohesive reason (e.g., small, tightly related helper modules). Prefer one top-level module per file for clarity.
- Never use map access syntax (
changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such asmy_struct.fieldor use higher level APIs that are available on the struct if they exist,Ecto.Changeset.get_field/2for changesets - Prefer the standard library (
Time,Date,DateTime,Calendar) for date and time. Add dependencies only when truly necessary (e.g., parsing exotic formats). - Don't use
String.to_atom/1on user input (memory leak risk) - Predicate function names should end with
?(e.g.,empty?). Reserveis_for guard macros or avoid it altogether; do not prefix regular predicate functions withis_. - Name OTP processes in child specs when you need to reference them (e.g.,
{DynamicSupervisor, name: MyApp.MyDynamicSup}). Use the registered name with calls likeDynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec). - Use
Task.async_stream(collection, callback, options)for concurrent enumeration with back-pressure. The majority of times you will want to passtimeout: :infinityas option
Mix guidelines
- Read the docs and options before using tasks (by using
mix help task_name) - To debug test failures, run tests in a specific file with
mix test test/my_test.exsor run all previously failed tests withmix test --failed mix deps.clean --allis almost never needed. Avoid using it unless you have good reason
Phoenix guidelines
- Remember Phoenix router
scopeblocks include an optional alias which is prefixed for all routes within the scope. Always be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - You never need to create your own
aliasfor route definitions! Thescopeprovides the alias, ie: scope "/admin", AppWeb.Admin do pipe_through :browser live "/users", UserLive, :index end the UserLive route would point to theAppWeb.Admin.UserLivemodule Phoenix.Viewno longer is needed or included with Phoenix, don't use it
Ecto Guidelines
- Preload Ecto associations when they will be accessed (e.g., in templates) to avoid N+1 queries.
- Remember
import Ecto.Queryand other supporting modules when you writeseeds.exs - In
Ecto.Schema, choose field types appropriate to the column (:stringfor VARCHAR,:stringor:stringwith:binary_idfor IDs,:integer,:naive_datetime, etc.). Use:stringfor textual data;:textis a database column type, not an Ecto field type. Ecto.Changeset.validate_number/2DOES NOT SUPPORT the:allow_niloption. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed- You must use
Ecto.Changeset.get_field(changeset, :field)to access changeset fields - Fields which are set programatically, such as
user_id, must not be listed incastcalls or similar for security purposes. Instead they must be explicitly set when creating the struct
Phoenix HTML guidelines
- Phoenix templates always use
~Hor .html.heex files (known as HEEx), never use~E - Always use the imported
Phoenix.Component.form/1andPhoenix.Component.inputs_for/1function to build forms. Never usePhoenix.HTML.form_fororPhoenix.HTML.inputs_foras they are outdated - When building forms always use the already imported
Phoenix.Component.to_form/2(assign(socket, form: to_form(...))and<.form for={@form} id="msg-form">), then access those forms in the template via@form[:field] - Always add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (
<.form for={@form} id="product-form">) - For "app wide" template imports, you can import/alias into the
my_app_web.ex'shtml_helpersblock, so they will be available to all LiveViews, LiveComponent's, and all modules that douse MyAppWeb, :html(replace "my_app" by the actual app name) - Elixir supports
if/elsebut **does NOT supportif/else iforif/elsif. Never useelse iforelseifin Elixir, always usecondorcasefor multiple conditionals. Never do this (invalid): <%= if condition do %> ... <% else if other_condition %> ... <% end %> Instead always do this: <%= cond do %> <% condition -> %> ... <% condition2 -> %> ... <% true -> %> ... <% end %> - HEEx require special tag annotation if you want to insert literal curly's like
{or}. If you want to show a textual code snippet on the page in a<pre>or<code>block you must annotate the parent tag withphx-no-curly-interpolation: <code phx-no-curly-interpolation> let obj = {key: "val"} </code> Withinphx-no-curly-interpolationannotated tags, you can use{and}without escaping them, and dynamic Elixir expressions can still be used with<%= ... %>syntax - HEEx class attrs support lists, but you must always use list
[...]syntax. You can use the class list syntax to conditionally add classes, always do this for multiple class values: <a class={[ "px-2 text-white", @some_flag && "py-5", if(@other_condition, do: "border-red-500", else: "border-blue-100"), ... ]}>Text</a> and always wrapif's inside{...}expressions with parens, like done above (if(@other_condition, do: "...", else: "...")) and never do this, since it's invalid (note the missing[and]): <a class={ "px-2 text-white", @some_flag && "py-5" }> ... => Raises compile syntax error on invalid HEEx attr syntax - Never use
<% Enum.each %>or non-for comprehensions for generating template content, instead always use<%= for item <- @collection do %> - HEEx HTML comments use
<%!-- comment --%>. Always use the HEEx HTML comment syntax for template comments (<%!-- comment --%>) - HEEx allows interpolation via
{...}and<%= ... %>, but the<%= %>only works within tag bodies. Always use the{...}syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. Always interpolate block constructs (if, cond, case, for) within tag bodies using<%= ... %>. Always do this: <div id={@id}> {@my_assign} <%= if @some_block_condition do %> {@another_assign} <% end %> </div> and Never do this – the program will terminate with a syntax error: <%!-- THIS IS INVALID NEVER EVER DO THIS --%> <div id="<%= @invalid_interpolation %>"> {if @invalid_block_construct do} {end} </div>
Phoenix LiveView guidelines
- Never use the deprecated
live_redirectandlive_patchfunctions, instead always use the<.link navigate={href}>and<.link patch={href}>in templates, andpush_navigateandpush_patchfunctions LiveViews - Avoid LiveComponent's unless you have a strong, specific need for them
- LiveViews should be named like
AppWeb.WeatherLive, with aLivesuffix. When you go to add LiveView routes to the router, the default:browserscope is already aliased with theAppWebmodule, so you can just dolive "/weather", WeatherLive - Remember anytime you use
phx-hook="MyHook"and that js hook manages its own DOM, you must also set thephx-update="ignore"attribute - Never write embedded
<script>tags in HEEx. Instead always write your scripts and hooks in theassets/jsdirectory and integrate them with theassets/js/app.jsfile
LiveView streams
- Always use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items -
stream(socket, :messages, [new_msg]) - resetting stream with new items -
stream(socket, :messages, [new_msg], reset: true)(e.g. for filtering items) - prepend to stream -
stream(socket, :messages, [new_msg], at: -1) - deleting items -
stream_delete(socket, :messages, msg)
- basic append of N items -
- When using the
stream/3interfaces in the LiveView, the LiveView template must 1) always setphx-update="stream"on the parent element, with a DOM id on the parent element likeid="messages"and 2) consume the@streams.stream_namecollection and use the id as the DOM id for each child. For a call likestream(socket, :messages, [new_msg])in the LiveView, the template would be: <div id="messages" phx-update="stream"> <div :for={{id, msg} <- @streams.messages} id={id}> {msg.text} </div> </div> - LiveView streams are not enumerable, so you cannot use
Enum.filter/2orEnum.reject/2on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you must refetch the data and re-stream the entire stream collection, passing reset: true: def handle_event("filter", %{"filter" => filter}, socket) do # re-fetch the messages based on the filter messages = list_messages(filter) {:noreply, socket |> assign(:messages_empty?, messages == []) # reset the stream with the new messages |> stream(:messages, messages, reset: true)} end - LiveView streams do not support counting or empty states. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: <div id="tasks" phx-update="stream"> <div class="hidden only:block">No tasks yet</div> <div :for={{id, task} <- @stream.tasks} id={id}> {task.name} </div> </div> The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- Never use the deprecated
phx-update="append"orphx-update="prepend"for collections
LiveView tests
Phoenix.LiveViewTestmodule andLazyHTML(included) for making your assertions- Form tests are driven by
Phoenix.LiveViewTest'srender_submit/2andrender_change/2functions - Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- Always reference the key element IDs you added in the LiveView templates in your tests for
Phoenix.LiveViewTestfunctions likeelement/2,has_element/2, selectors, etc - Never tests again raw HTML, always use
element/2,has_element/2, and similar:assert has_element?(view, "#my-form") - Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that
Phoenix.Componentfunctions like<.form>might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be - When facing test failures with element selectors, add debug statements to print the actual HTML, but use
LazyHTMLselectors to limit the output, ie: html = render(view) document = LazyHTML.from_fragment(html) matches = LazyHTML.filter(document, "your-complex-selector") IO.inspect(matches, label: "Matches")
Form handling
Creating a form from params
If you want to create a form based on handle_event params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to to_form/1, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The :as option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to to_form:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under %{"user" => user_params}.
In the template, the form form assign can be passed to the <.form> function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like id="todo-form".
Avoiding form errors
Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template. In the template always access forms this:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And never do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- Never use
<.form let={f} ...>in the template, instead always use<.form for={@form} ...>, then drive all form references from the form assign as in@form[:field]. The UI should always be driven by ato_form/2assigned in the LiveView module that is derived from a changeset