README.md

# 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.context` walks 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.all` runs the invariant suite against all modules
  and the project manifest. Exits non-zero on `:error` violations, passes with warnings.
- **Runtime health picture** — `mix foundry.project.status` composes 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.exs` in your target project root

---

## Installation

Add to your `mix.exs` dependencies:

```elixir
{: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:

```bash
mix deps.get
```

---

## Quickstart

**1. Create your project manifest:**

```bash
mkdir -p .foundry
```

Create `.foundry/manifest.exs` — see [The Manifest](#the-manifest-foundrymanifestexs) below.

**2. Generate the system map:**

```bash
mix foundry.project.context
```

Emits a JSON graph of all Ash/Spark modules to stdout and writes `.foundry/context.lock`.

**3. Check project health:**

```bash
mix foundry.project.status
```

**4. Run the lint suite:**

```bash
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:**

```elixir
# .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`](../../docs/manifest-schema-draft.md)

---

## Mix Tasks

### `mix foundry.project.context`

Generates the project system map.

```bash
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 check
```

**Bulk output** emits JSON to stdout:

```json
{
  "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` — success
- `1` — lock stale or absent (only with `--check`)

Schema: [`docs/project_context_schema.md`](../../docs/project_context_schema.md)

---

### `mix foundry.project.status`

Emits a runtime health snapshot as JSON.

```bash
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`](../../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.

```bash
mix foundry.lint.all                # JSON output (default)
mix foundry.lint.all --format=text  # human-readable summary
```

**Exit codes:**
- `0` — no `:error` violations (warnings and info are non-blocking)
- `1` — one or more `:error` violations
- `2` — lint runner crash (rule bug)

**Output shape:**

```json
{
  "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`](../../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):

```yaml
- name: Check context lock
  run: mix foundry.project.context --check

- name: Run lint suite
  run: mix foundry.lint.all
```

**Context 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:

```bash
mix foundry.project.context
git add .foundry/context.lock
```

**Optional — write CI status for the Studio health panel:**

After your CI run, write `.foundry/ci_status.json`:

```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`:

```elixir
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
end
```

**2.** Register it in `Foundry.LintRules.Registry`:

```elixir
@module_rules [
  ...,
  Foundry.LintRules.YourRule
]
```

**3.** Add the rule to [`docs/lint-catalogue.md`](../../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.