Ash Enterprise
A reference Elixir/Ash application: a base template for enterprise software —
ERP, workflow management, line-of-business systems — with an opinionated answer
for every cross-cutting concern, and a repeatable process for growing the domain
model over time.
The argument is in docs/manifesto/. The code is
the evidence. Several things here look unusual on purpose; the reasoning is
written down.
The capstone is published: a vet-clinic demo carved from this tree, showing
agents reading an Ash codebase instead of grepping it —
lukegalea/clinic-demo, with its
story and evidence index in docs/capstone/storyline.md.
The thesis
The cross-cutting concerns of enterprise software are declarable. Declare them
once, derive them everywhere, and what is left over is the actual business.
Enterprise software is not hard because any one feature is hard. It is hard
because ownership, hierarchical access control, audit, versioning, multitenancy,
lifecycle and integration surfaces cross-cut every entity. Written per feature,
they decay. Declared once, they cannot.
Every enterprise application answers the same questions
Not the same features — the same questions. Who owns this record; who may see it; what happened to
it and can you prove it; whose tenant is it; where did the data come from; how does the contract change
without breaking the callers you cannot see. Most systems answer them one feature at a time, and find
out in month nine which answers disagree.
This is the ledger. ✅ means working code plus a test that would fail if it regressed; 🟡 means it
works with a limitation stated in the answer; 🔵 means a decision is written down and no code exists;
⚪ means a gap with no decision taken.
17 of 54 enterprise questions have a shipped answer.
✅ Shipped 17 · 🟡 Partial 16 · 🔵 Planned 7 · ⚪ Open 14
| Section | Shipped | Partial | Planned | Open |
|---|
| Who are you, and what may you do? | 4 | 2 | 0 | 4 |
| What happened, and can you prove it? | 8 | 1 | 0 | 2 |
| Whose data is it? | 2 | 2 | 0 | 2 |
| Where does data come from, and where does it go? | 0 | 2 | 5 | 2 |
| How does it change, and keep running? | 3 | 6 | 2 | 1 |
| Can you prove it, continuously? | 0 | 3 | 0 | 3 |
| # | Question | Our answer | Status |
|---|
| 1 | How do people prove who they are? | Password, magic link, OAuth2/OIDC and API keys via ash_authentication — an integration seam for the identity provider your enterprise already runs, not a second one. Federate to Okta, Entra ID or Keycloak and the provider owns MFA, passkeys and session policy; the in-app stack is the lightweight version the reference app needs to run standalone. SAML and WebAuthn in-app are the planned seam, for deployments that must authenticate in-app. | 🟡 Partial |
| 2 | How is "may this actor do this?" decided? | (role, privilege, depth) rows evaluated as a pure union of grants — authorization is data, not code. No deny rules, so order cannot matter. | ✅ Shipped |
| 29 | How do enterprise customers bring their own identity provider? | Bringing your own IdP is the supported topology, not the exception. AshAuthentication federates to Okta, Entra ID, Keycloak or any OIDC provider through its OAuth2 strategy, and the provider — not this platform — owns MFA, password policy and directory truth. SAML is the one protocol that needs a first-party strategy; it is planned on the same seam for deployments that must authenticate in-app. | ⚪ Open |
| 3 | Who owns a record? | Polymorphic user-or-team ownership inherited from the base resource. The Dataverse ownership type per entity is scraped from the corpus, never guessed. | ✅ Shipped |
| 30 | How do accounts appear and disappear when someone joins or leaves? | Joining and leaving is your directory's decision, and the IdP your enterprise runs is the system of record for it. Today users are created by registration and deactivated by a lifecycle transition; the planned SCIM seam is a resource-backed endpoint over those same actions, so the directory drives the lifecycle this platform already enforces — no parallel user store, no second copy of the truth. | ⚪ Open |
| 31 | What second factor is required, and how are sessions bounded? | With enterprise SSO the second factor is the provider's: Okta, Entra ID and Keycloak enforce MFA, step-up and session lifetime at the boundary this platform federates to. For the lightweight in-app login the reference app ships, MFA enforcement, step-up and configurable session lifetime are not built yet; they are planned as part of the identity seam, alongside WebAuthn. | ⚪ Open |
| 32 | Who may act as someone else, and is that recorded differently from acting as yourself? | Partial. Acting on someone else's behalf is now represented end to end: the record's names the operator while names the customer, and every audit event carries . What is missing is the gate — nothing yet decides who may impersonate whom, or records a session with a stated reason. |
The long form, with what each answer is proven by, is docs/QUESTIONS.md. The ⚪
rows are argued at length in thesis 7.
Getting started
devenv up -d # Postgres (with pgvector)
devenv shell -- mix setup # deps, database, migrations
devenv shell -- mix ash_enterprise.seed # a tenant, a role, a user
devenv shell -- mix ash_enterprise.demo # the demo tenant: a business-unit tree, people, a clinic
devenv shell -- iex-server # the app
Starting a new application from this template? Read
docs/BLEEDING-EDGE.md first. Two dependencies are forks of
upstream packages, and 13 of our own packages are not on Hex yet. That page
says which, why, and the path back to a Hex release for each.
seed provisions the minimum — one organization, one business unit, one
administrator — which is what tests want and what a first run needs. demo
creates a tenant of its own (clinic-demo, administrator [email protected]) and fills it,
touching no other tenant, so it is safe on a database that holds real ones. Most of what this platform argues for is invisible against
a single row: grant depth needs a subtree to be measured over, and policies that
differ per actor need a second actor. Everything it creates goes through the
application's own actions, so the people it adds can sign in.
Sign in with the credentials the seeder prints, then visit:
| URL | What |
|---|
/app | The launcher — every surface in the registry (AshEnterprise.Surfaces), grouped, Today leading; sign-in lands here (/app/hub and /app/demo redirect here) |
/app/mdm/import | MDM demo: import a customer CSV (upload → AI-proposed mapping → activate → run) |
/app/mdm | MDM demo: steward queue — side-by-side diffs awaiting confirm / reject / unsure (reject also in bulk) |
/app/mdm/golden | MDM demo: golden records after the confirm, with their source identities and merge history |
/app/mdm/batches | MDM demo: import batches as a board by status, each with its counts and staged rows |
/app/tasks | Every process task waiting on you, as a board: claim, complete, delegate |
/app/processes | The mdm.steward_review BPMN process the review candidates run (banding itself is a DMN table) |
/app/flights/stewardship, /app/flights/briefs | Flight views: a process diagram with a marker on the step each run is on, moved live as the engine advances; /app/flights shows one run |
/agent | Helper console — proposes changes for your approval, and shows or composes tables on request |
/app/users, /app/teams, /app/roles, /app/business-units | A2UI surfaces derived from resource metadata |
/app/legacy-users | The same, over a 2010-era Rails schema read through a compatibility view — and it updates when the old application writes |
/admin | Zero-config admin over every resource |
/canvas | The domains, resources and relationships as one object graph, with the selected resource's surface beneath it (dev only) |
/clarity | ER, class, policy and state-machine diagrams (dev only) |
/api/json/swaggerui · /gql/playground |
The MDM demo, end to end (fresh DB): sign in as [email protected] /
password1234 — the demo tenant owns the demo data.
mix ash_enterprise.demo seeds the machinery and one in-flight import:
a golden "Ann Existing" with a merged duplicate, and a review-banded
match waiting at /app/mdm.
- Upload any CSV at
/app/mdm/import, click AI proposal, review the
column mapping, Validate & activate, then Run import.
- Low-confidence matches land at
/app/mdm: Confirm match merges the
staged record into its golden; Reject match keeps it a separate
person (several at once, from a selection); Mark unsure records the
verdict and leaves it open for a senior steward.
/app/mdm/golden shows the survivors and /app/mdm/batches the import
itself; the screens link to each other.
The seeded review candidate is consumed once decided — re-run
mix ash_enterprise.demo to reseed one (it is idempotent otherwise).
What it looks like
No markup was written for any of this. Every surface here is derived from the
same resource definitions: the screenshots are what you get for declaring a
resource, before writing any UI.
The launcher at /app is the shortest path through all of it, and the
place to start after seeding. It, the nav and the ACP command palette are all
derived from one surface registry, AshEnterprise.Surfaces.
A2UI surfaces. One page per resource, rendered from resource metadata. The
grid, the filter and the tasks are all derived; the actor and the tenant are the
only things the LiveView supplies, so each surface is filtered by exactly the
policies that guard the API.
What a surface offers is derived too, from the actions that exist. A resource
with an update action gets Edit on the row; one without gets View alone —
which is why /app/legacy-users below is read-only without anything in this
application saying so, the strangler read model having declared defaults [:read]
and nothing else. Pagination appears when there is another page to go to. A form
appears when you start a task, headed with which task it is, and its button says
what it will do.
What is declared is a dozen lines of layout intent per surface — which field is
the title, which is the status badge, what belongs in the metadata grid — because
a derived screen still has to be told what matters. Nothing in that declaration
is styling; A2UI's components carry no spacing or colour props at all, in any
version of the spec. Appearance comes from a CSS custom-property contract that
assets/css/neobrutalism.css maps onto the app's design tokens, so these pages
follow the same theme and the same dark/light toggle as the rest of the app
(ADR 0050).
The same generator, three more resources — users, teams and security roles:
The helper console. Ask for an administrative change in plain language. The
model plans and returns a struct; it never holds the mutation, so there is no
tool for a prompt injection to reach.
Here it has interpreted "Give [email protected] the Auditor role". The names are
resolved as you, against records you are allowed to see — so a user you
cannot read comes back as "not found" rather than as a proposal referencing a
record you have no business knowing exists. Authorization is checked before this
card is rendered, so you are never asked to confirm something that will then
fail:
Approving executes it as you, through the same policies as the admin UI. The
audit row names the human who approved, not the model that suggested:
Interpreting a request needs a provider key. Everything after it — resolution,
authorization, execution, audit — is ordinary Ash code and does not, which is why
the flow is exercised end to end by the test suite without one.
Not every request is a change, and treating one as if it were is how people learn
to click past confirmations. Asked for a table, the console renders one of the
surfaces the application already declares — filtered by your policies before a
single row reaches the page, so there is nothing to confirm:
When no declared surface fits, it composes one. What the model returns is a
spec, not UI: a resource name, field names, a sort. The server resolves every
name against a host-configured allowlist and runs the same verifiers the
compile-time DSL runs, so a spec naming a field that does not exist is refused
with an error rather than rendered blank:
A legacy write, showing up. legacy.users is a simulated 2010-era Rails
schema this application does not own — serial primary key, no tenant column,
acts_as_paranoid, SHA1 passwords. One ash_strangler mapping turns it into an
ordinary resource here and emits an AFTER trigger that pg_notifys on
commit. A listener re-reads the row through Ash and dispatches a real
notification, so nothing downstream can tell which application wrote it:
Nothing there is staged: the row arrives from a plain INSERT INTO legacy.users
issued with psql — no Ash, no changeset, no HTTP request. See
priv/legacy/README.md for the commands.
Master data, end to end. /app/mdm/import → /app/mdm → /app/mdm/golden
is the entity-resolution loop the ADR 0054 plan asked for, driven entirely
through the UI. Upload a CSV, let the AI propose the mapping (activation stays
a person's act), run the batch, and review-banded matches wait in the steward
queue as side-by-side diffs with survivorship winners:
Confirming merges the staged record into its golden; the golden surface lists
the survivors, with the merge lineage itself answerable as data (merged,
master_id, the SourceIdentity XREF) rather than drawn:
The object graph. /canvas is the application's own structure: every domain,
every resource, and the relationships between them, derived from Ash metadata
alone. No record is ever a node — the graph is declared structure, not a query,
so opening it reads nothing.
Selecting a node resolves it server-side into a naked object: what it is,
where it came from, one Ash.can?/3 per action with your actor, and the
projections it offers — the ways this application can show it. The graph
focuses on the selection and its immediate neighbours, and the object's own
A2UI surface opens underneath, so the thing you just pointed at is also the
thing you can now browse and edit.
Projections are where the object model stops being a diagram of the database.
Most are derived from the actions a resource declares — a read makes it
browsable, an update makes it editable — but some cannot be. A BPMN process
definition and a business unit have indistinguishable actions; what differs is
that this application ships bpmn-js and points it at one of them, which is a
fact about the host rather than the resource. So the host declares those
(ADR 0037), and every
projection that has somewhere to go renders as a link beside it. A process
definition offers diagram and opens in the modeller; approvals open in the
task list. None of those pages is a derived table, and until they were reachable
this way the whole process layer sat in the graph as objects with nowhere to go.
It is dev-only, behind the same flag as /clarity, and serves the same
audience.
AshAdmin. Every resource, every action, no configuration.
GraphQL. The schema — types, filter inputs, sort inputs, pagination — is
generated from the resources that opt into an api_type. Nothing in the list
below was hand-written.
What is here
A platform layer. AshEnterprise.Platform.Resource is the base resource
every resource uses. Ownership, provenance, lifecycle, concurrency, tenancy,
audit, soft delete, telemetry and authorization arrive by inheritance — not by
per-resource discipline.
Authorization as data. A faithful implementation of the Dataverse security
model: (role, privilege, depth) rows evaluated as a pure union of grants,
with business-unit hierarchy, per-record sharing, and manager/position hierarchy.
test/ash_enterprise/security/conformance_test.exs is the truth table, and it is
the first thing to read when the model surprises you.
A schema commons. Entities derived from the Microsoft Common Data Model —
vendored at a pinned commit, resolved offline, committed as flat JSON. 43 CDM
entities plus 18 from the Dataverse table reference. Do not invent your nouns.
Agents as first-class users. The same Ash actions and policies back the web
UI, JSON:API, GraphQL and MCP. An LLM tool is a declaration that an existing
action may be invoked, not a parallel code path — which is why there is no
agent-specific authorization, and therefore no agent-specific authorization bug.
Compliance engine
Compliance is projected from events under an immutable rule bundle
(ADR 0035). Three
first-party packages carry it; this application wires them into one vertical
slice, customer KYC:
ash_rules — the rule engine seam: a serializable rule IR, a
fact-schema DSL, and evaluator behaviour. Rules are data with verifiers at
admission, never quoted code.
ash_compliance — the control plane and the data plane. Catalogs,
profiles, waivers and rule-set revisions compile — through a fixed,
non-negotiable layering precedence — into immutable, content-hashed
PolicyBundle snapshots; findings project out of a compliance event log,
and every evaluation is appended, pinning the bundle hash, the fact
snapshot and the outcome. Missing evidence is unknown, never compliant.
ash_strangler (ledger) — the change ledger the legacy trigger writes,
and the drain that turns a legacy write into a canonical projection plus a
KYC review in one transaction, attributed to the projection system actor.
The chain, end to end: legacy write → ledger row → drain → canonical action
→ event → projector → finding. The subject reads its own state back as
ordinary calculations (kyc_status, compliant?, gap_count on
ProjectedUser); auditors read the evaluation log, which no code path rewrites.
Run the demo program for the legacy estate's tenant:
mix ash_enterprise.compliance.seed # catalog, layers, profile, one bounded waiver, active bundle
The admin surfaces live at /app/compliance/* (findings, evaluations,
bundles, rule sets, catalogs, profiles), behind the compliance door — the same
(role, privilege, depth) grant the rest of the security model resolves.
Where this is going
The 🔵 rows above, sequenced. Each is an ADR you can read and disagree with rather than a promise, and
each had to clear one bar: can the tool be handed a thin generated declaration and delegate
authorization back to ActorContext, or does it bring its own? A tool with its own RBAC is a second
copy of the security model to keep synchronized, and synchronization jobs diverge silently in the
permissive direction.
| Priority | Gap | Choice | Status | ADR |
|---|
| 1 | Approvals and maker-checker | ash_bpmn — no external engine | 🟡 Partial | ADR 0015 |
| 1 | Audit retention, partitioning and erasure | Time-partitioned hot/cold split; erasure by crypto-shredding | 🟡 Partial | ADR 0024 |
| 1 | Business rules and decision tables | ash_decisions — DMN, engine adopted not written | 🟡 Partial | ADR 0028 |
| 1 | Control mapping and evidence | Generated from the ledger; SOC 2, ISO 27001, GDPR | 🟡 Partial | ADR 0021 |
| 1 | Data ingestion | Meltano (MIT); Airbyte as the fallback | 🟡 Partial | ADR 0010 |
| 1 | Enterprise identity: SAML, OIDC and SCIM | Federate to the enterprise IdP over OIDC today; SAML and SCIM as planned strategies on the same seam | 🔵 Planned | — |
| 1 | Integration hub — avoiding M×N | Nango for the provider edge only | 🟡 Partial | ADR 0011 |
| 1 | Legacy migration, process modelling and decisions as platform extensions | ash_strangler + ash_bpmn + ash_decisions, first-party | ✅ Shipped | ADR 0009 |
Two strong products lose on that bar outright. Metabase: row-level security sits behind the paid
tier even when self-hosted, so adopting it would put the authorization thesis behind a paywall.
Camunda and Flowable: a workflow engine is a second identity and assignment model, and it expresses
maker-checker as a deny rule — which non-negotiable 2 forbids.
Two expected answers were reversed by checking them, and both reversals are in the ADRs rather than
quietly dropped. Unleash lost to FunWithFlags once its Elixir SDK turned out to draw ~151 downloads a
month against ~1M, and the end-of-life everyone cites attaches to Edge OSS rather than the server. And
the catalogue choice is far closer than it looks: OpenMetadata needs Elasticsearch as well as Postgres,
its OpenLineage ingest is a Kafka consumer rather than an HTTP endpoint, and it has more GitHub stars
than DataHub — so the cheap upgrade path from Marquez is DataHub, not OpenMetadata. ADRs 0012 and 0013
do not compose as neatly as the pairing suggests, and both say so.
The reasoning, the sequencing and what stays deliberately open is docs/ROADMAP.md.
Non-negotiables
- Every resource uses
AshEnterprise.Platform.Resource. Opting out is explicit
and greppable, never silent.
- Never use
forbid_if for row access. The model is a pure union; one
forbid_if reintroduces order-dependence.
- Policy checks never query. Everything is precomputed once per request into
ActorContext. A check that queries is a bug, not a slow path.
- Run
mix ash.codegen after changing a resource. Drift is a CI failure.
- Never edit
priv/cdm/schemaDocuments/ — vendored CC-BY-4.0 content.
- An external tool consumes
ActorContext; it never keeps its own copy. A
service with its own RBAC, tenancy or audit trail is a second security model
to synchronize, and synchronization diverges silently in the permissive
direction. Removing such a service must degrade a feature, never break the app.
Documentation
| Where | What |
|---|
docs/QUESTIONS.md | The ledger — every question, our answer, and what proves it |
docs/COMPLIANCE.md | The control map — SOC 2, ISO 27001 and GDPR controls, and the test that proves each |
docs/ROADMAP.md | Where the gaps go, in what order, and what each choice beat |
docs/HANDOFF.md | Start here in a new session — current state, and findings that cost time to discover |
docs/manifesto/ | The eight theses, including what we do not have |
docs/adr/ | Decision records, with the reversal path for each |
docs/BLEEDING-EDGE.md | Forks and unreleased packages — what is not a Hex release, and the path back for each |
docs/plans/ | Specifications: strangler-fig migrations, process modelling, API versioning |
|
Honest limits
Read thesis 7 before committing to
this stack. The ⚪ rows above are the short version: no WebAuthn or SAML, no
column-level security actually declared, no retention or erasure story for an
append-only audit log inherited by every resource, no content i18n, and no
Dialyzer certainty against Spark-generated code. Each is named rather than
glossed, and the last two paragraphs of thesis 7 explain why naming them is the
only commitment worth making about gaps.
Thesis 4 currently claims field policies that lib/ does not
contain. The claim is being corrected rather than quietly implemented.
Licence
MIT — see LICENSE. Two vendored corpora are excluded and carry their
own terms: priv/cdm/schemaDocuments/ is Microsoft's Common Data Model under
CC-BY-4.0, and priv/cdm/resolved/dataverse_*.json derives from Microsoft's
Dataverse table reference. Attribution is required for both; see
priv/cdm/ATTRIBUTION.md.
This is a template, not a product. Clone it, delete what you do not need, and
keep the shape.