Foundry
A governed build environment for Elixir/Ash/Phoenix platforms. Foundry extracts a typed system map from your project, enforces invariants via a lint suite, and provides a structured runtime health picture for CI and the Foundry Studio UI.
What Foundry Does
- System map extraction —
mix foundry.project.contextwalks all compiled Ash/Spark modules and emits a typed JSON graph of nodes (resources, reactors, domains) and edges (writes, reads, async, references). - Governance lint —
mix foundry.lint.allruns the invariant suite against all modules and the project manifest. Exits non-zero on:errorviolations, passes with warnings. - Runtime health picture —
mix foundry.project.statuscomposes lint results, pending migrations, stack versions, CI state, and compliance coverage into a single JSON snapshot.
Requirements
- Elixir ~> 1.15
- Ash ~> 3.20 (Ash 2.x is not supported)
-
A
.foundry/manifest.exsin your target project root
Installation
Add to your mix.exs dependencies:
{:foundry, "~> 0.1", only: [:dev, :test]}For umbrella projects, add to the specific app that owns the build tooling. Foundry does not need to be in every umbrella app's deps.
Run:
mix deps.getQuickstart
1. Create your project manifest:
mkdir -p .foundry
Create .foundry/manifest.exs — see The Manifest below.
2. Generate the system map:
mix foundry.project.context
Emits a JSON graph of all Ash/Spark modules to stdout and writes .foundry/context.lock.
3. Check project health:
mix foundry.project.status4. Run the lint suite:
mix foundry.lint.all
The Manifest (.foundry/manifest.exs)
Foundry reads .foundry/manifest.exs from the project root. This file is committed to
your project repository. It is a plain Elixir keyword list.
Minimum required manifest:
# .foundry/manifest.exs
[
project_name: "MyApp",
domain_type: :other, # :igaming | :fintech | :healthcare | :legal | :insurance | :other
approvers: [
sensitive_lead: "lead@company.com",
compliance_officer: "compliance@company.com"
],
sensitive_resources: [
MyApp.Finance.LedgerEntry,
MyApp.Finance.Wallet
# ash_authentication User and Token resources are always treated as sensitive —
# do not list them here; Foundry detects them automatically.
]
]
The manifest is validated at startup. Invalid manifests prevent tasks from running.
Required fields: project_name, approvers.sensitive_lead, approvers.compliance_officer.
Full schema reference: docs/manifest-schema-draft.md
Mix Tasks
mix foundry.project.context
Generates the project system map.
mix foundry.project.context # full graph — all modules
mix foundry.project.context MyApp.Finance.Wallet # single module NodeEntry
mix foundry.project.context --check # CI staleness checkBulk output emits JSON to stdout:
{
"generated_at": "2026-03-22T10:00:00Z",
"project": "MyApp",
"project_type": "standard",
"domain_type": "igaming",
"nodes": [ "..." ],
"edges": [ "..." ],
"spec_kit": { "..." },
"graph_delta": null
}
Also writes .foundry/context.lock — a SHA256 hash of all lib/**/*.ex and
test/**/*.ex files. Use --check in CI to verify the lock is current.
Exit codes:
0— success1— lock stale or absent (only with--check)
Schema: docs/project_context_schema.md
mix foundry.project.status
Emits a runtime health snapshot as JSON.
mix foundry.project.status
mix foundry.project.status --json # compact output (no pretty-print)Output includes: compilation timestamp, stack versions, lint summary, pending migrations, open proposals, compliance matrix, CI state, and manifest metadata.
Schema: docs/mix_task_summary_schemas.md
mix foundry.lint.all
Runs the full Foundry lint suite. Emits a JSON report and exits non-zero if any :error
violations are found.
mix foundry.lint.all # JSON output (default)
mix foundry.lint.all --format=text # human-readable summaryExit codes:
0— no:errorviolations (warnings and info are non-blocking)1— one or more:errorviolations2— lint runner crash (rule bug)
Output shape:
{
"passed": false,
"error_count": 1,
"warning_count": 2,
"info_count": 0,
"violations": [
{
"rule_id": "missing_paper_trail",
"severity": "error",
"message": "MyApp.Finance.Wallet is sensitive but does not use AshPaperTrail.Resource",
"module": "MyApp.Finance.Wallet"
}
],
"generated_at": "2026-03-22T10:00:00Z"
}Lint Rules
The following rules run in mix foundry.lint.all.
Full catalogue including planned rules: docs/lint-catalogue.md
| Rule ID | INV | Severity | Description |
|---|---|---|---|
missing_paper_trail | INV-011 | :error |
Sensitive resource missing AshPaperTrail.Resource extension |
missing_archival | INV-012 | :error |
Sensitive resource missing AshArchival.Resource extension |
missing_runbook | INV-005 | :error |
Reactor with >3 steps missing @runbook declaration |
missing_idempotency | INV-004 | :error | Reactor with side-effect steps missing idempotency key |
missing_description | INV-006 | :error |
Ash resource attribute missing description: value |
ash_version_outdated | — | :error |
Resolved ash version in mix.lock is below 3.x |
elixir_version_unsupported | — | :error | Elixir version below minimum required for Ash 3.x |
adapter_version_not_active | — | :warning | Provider adapter registered but not active |
manifest_missing_required_approver | — | :error | approvers.sensitive_lead or compliance_officer absent |
manifest_invalid_coverage_weights | — | :error | coverage_weights values do not sum to 1.0 |
manifest_missing_cldr_backend | — | :error | :ash_money declared but no CLDR backend discoverable |
Rules are registered in Foundry.LintRules.Registry. New rules must be added there
explicitly — accidental registration is worse than deliberate omission.
CI Integration
Add to your CI pipeline (GitHub Actions example):
- name: Check context lock
run: mix foundry.project.context --check
- name: Run lint suite
run: mix foundry.lint.allContext lock check:mix foundry.project.context --check computes
sha256(lib/**/*.ex + test/**/*.ex) and compares it against .foundry/context.lock.
Fails if the lock is absent or stale.
Regenerate the lock whenever source files change:
mix foundry.project.context
git add .foundry/context.lockOptional — write CI status for the Studio health panel:
After your CI run, write .foundry/ci_status.json:
{
"last_run_at": "2026-03-22T10:00:00Z",
"commit": "a3f9d12",
"branch": "main",
"lint_passed": true,
"tests_passed": true
}mix foundry.project.status merges this file into the ci field of its output.
The file must live at exactly .foundry/ci_status.json in the project root.
Change Classification
Every proposed change is classified into one of four classes. Classification determines approval requirements.
| Class | When | Approver | Auto-apply |
|---|---|---|---|
:structural | New resource, attribute, relationship, description, test skeleton | Any developer |
Configurable (auto_apply_structural) |
:behavioral | New Rule, Transfer step, Reactor, Oban job, state machine transition | Domain lead | Never |
:sensitive |
Changes to resources in manifest.sensitive_resources | Sensitive lead + one other (dual approval) | Never |
:compliance | Changes to compliance declarations, policy modules, compliance-gated flags | Compliance officer | Never — ADR link required |
When in doubt, classify upward. A :behavioral change misclassified as :structural
and auto-applied is a governance failure. The reverse is merely inconvenient.
Internal Architecture (Contributors)
Module Map
| Module | Role |
|---|---|
Foundry.FileSystem | Validated read boundary — all file access routes through here; enforces permitted root paths |
Foundry.SparkMeta | Spark DSL walker — extracts typed information from compiled modules |
SparkLint.Rule (package) |
Behaviour for lint rules: check/2 → {:ok, [violation]} |
SparkLint.Runner (package) | Executes rules across all modules, collects violations |
SparkLint.Violation (package) |
Violation struct: rule_id, module, message, severity, step, attribute |
Foundry.LintRules.* | Rule implementations — one module per rule |
Foundry.LintRules.Registry |
Explicit rule registration; module_rules/0 and manifest_validators/0 |
Foundry.Lint.Runner |
High-level orchestrator: discovers modules, runs registry rules, emits LintReport |
Foundry.Context.GraphBuilder | Assembles the full node+edge graph from all project modules |
Foundry.Context.NodeBuilder |
Constructs a NodeEntry from SparkMeta output |
Foundry.Context.NodeEntry | Core typed output struct for per-module context |
Foundry.Context.EdgeEntry | Typed edge between two nodes |
Foundry.Context.LockFile |
Writes and validates .foundry/context.lock |
Foundry.Context.ModuleDiscovery | Discovers all compiled project modules |
Foundry.Context.SpecKitIndexBuilder |
Walks docs/adrs/, docs/findings/, docs/runbooks/, docs/regulations/; populates spec_kit field |
Foundry.Context.SessionState | Captures system map state for graph delta computation |
Foundry.Manifest | Ash resource for manifest schema + validation (no database — Simple data layer) |
Foundry.Manifest.Parser |
Reads and parses .foundry/manifest.exs |
Foundry.Status | Composes the runtime health picture from all Phase 1 sources |
Foundry.Status.StackVersions |
Extracts resolved dependency versions from mix.lock |
Adding a Lint Rule
1. Create lib/foundry/lint_rules/your_rule.ex:
defmodule Foundry.LintRules.YourRule do
@behaviour SparkLint.Rule
def check(module, ctx) do
# ctx.metadata[:sensitive_modules] — list of sensitive module atoms
# ctx.modules — all discovered project modules
# ctx.metadata[:manifest] — parsed manifest keyword list
violations = []
{:ok, violations}
end
end2. Register it in Foundry.LintRules.Registry:
@module_rules [
...,
Foundry.LintRules.YourRule
]3. Add the rule to docs/lint-catalogue.md with
status: planned before implementing, status: active once shipped.
4. Write a test in test/foundry/lint_rules/your_rule_test.exs.
Rules must handle crashes gracefully. Use rescue around any Spark introspection calls —
Spark extensions may not be loaded for all modules in the project under test.