defmodule Foundry.Manifest do
@moduledoc """
The project manifest resource. Validates and provides typed access to the
`.foundry/manifest.exs` configuration file for a target project.
## Storage
The manifest lives as a plain Elixir keyword list at `.foundry/manifest.exs`
in the target project's repository (ADR-015). This Ash resource is the
schema + validation layer — it does not persist to a database.
`Ash.DataLayer.Simple` is used here solely to leverage Ash's changeset
validation, attribute type coercion, and embedded resource support. The
manifest is **always a single instance** — it is loaded once at Studio
startup and held in-process. It is never queried by ID, never paginated,
and never written to a database. `Ash.DataLayer.Simple` is the lightest
data layer that gives us Ash validations without any storage infrastructure.
Do not add `:read` actions that imply collection semantics (list, filter,
sort). The only meaningful action is `:load` — construct from a keyword list.
## Loading
Use `Foundry.Manifest.Reader.load!/1` to read and validate the manifest file.
That function parses the `.exs` file, constructs a `Foundry.Manifest` record
via `Ash.Changeset.for_create/3` with the `:load` action, and raises if
validation fails. The returned record is cached in ETS for the session
(ADR-015 Tier 2 — keyed on `{:manifest, mix_exs_mtime}`).
## Validation
All fields declared in `docs/manifest-schema-draft.md` are validated here.
Invalid manifests raise at Studio startup — Foundry will not run against a
project with a broken manifest.
## Calculations
Calculations on this resource use Elixir-native `expr/1` forms only.
SQL `fragment/1` expressions are not valid with `Ash.DataLayer.Simple` —
it evaluates expressions in Elixir, not Postgres. Any calculation that
appears to need a fragment should be implemented as a module-based
calculation instead.
## ADR
ADR-011 (deferred — write after this resource is stable in production).
Pre-ADR schema: `docs/manifest-schema-draft.md`.
"""
use Ash.Resource,
domain: Foundry.Config,
data_layer: Ash.DataLayer.Simple
# ---------------------------------------------------------------------------
# Attributes
# ---------------------------------------------------------------------------
attributes do
uuid_primary_key(:id)
# ── Identity ──────────────────────────────────────────────────────────────
attribute :project_name, :string do
description("Human-readable project name. Used in Studio UI headers and audit log records.")
allow_nil?(false)
end
attribute :domain_type, :atom do
description(
"Target domain category. Used for bootstrap template selection. One of: :igaming, :fintech, :healthcare, :legal, :insurance, :itsm, :sdlc, :logistics, :enterprise_internal, :other."
)
constraints(
one_of: [
:igaming,
:fintech,
:healthcare,
:legal,
:insurance,
:itsm,
:sdlc,
:logistics,
:enterprise_internal,
:other
]
)
default(:other)
end
# ── MCP / Agent Configuration ─────────────────────────────────────────────
attribute :mcp_enabled, :boolean do
description(
"When true, the Foundry MCP server is enabled and exposes project context tools to external agents (Claude Code, Cursor, etc.)."
)
default(false)
end
attribute :tidewave_scaffold, :boolean do
description(
"When true, Tidewave dev-time runtime intelligence tools are scaffolded and available."
)
default(false)
end
attribute :copilot_model, :string do
description(
"Default LLM model string for the copilot agent. Passed to ReqLLM.model!/1. E.g. 'anthropic:claude-sonnet-4-6'."
)
default("anthropic:claude-sonnet-4-6")
end
# ── Sensitive Resources ────────────────────────────────────────────────────
attribute :sensitive_resources, {:array, :string} do
description(
"Module names (as strings) of resources requiring dual approval, AshPaperTrail, and AshArchival. Authentication User and Token resources are always added automatically and must not be listed here."
)
default([])
end
attribute :sensitive_resource_exemptions, {:array, Foundry.Manifest.SensitiveExemption} do
description(
"Per-sensitive-resource exemptions for paper_trail or archival requirements. Each entry requires a documented reason and is a :compliance class change."
)
default([])
end
# ── Approvers ─────────────────────────────────────────────────────────────
attribute :approvers, Foundry.Manifest.ApproverConfig do
description(
"Named approver email addresses for each approval role. sensitive_lead and compliance_officer are required."
)
allow_nil?(false)
end
# ── Approval SLAs ─────────────────────────────────────────────────────────
attribute :approval_sla, Foundry.Manifest.ApprovalSla do
description(
"Time limits for each change class to reach approval. nil means no SLA. Operations board shows proposals exceeding their SLA."
)
default(nil)
end
# ── Auto-Apply ────────────────────────────────────────────────────────────
attribute :auto_apply_structural, :boolean do
description(
"When true, approved :structural proposals are applied immediately on approval. The approval action IS the apply trigger. All other classes always require a separate Apply action."
)
default(false)
end
# ── Phase Gate ────────────────────────────────────────────────────────────
attribute :change_generation_enabled, :boolean do
description(
"Controls whether the copilot generates real diffs (Phase 4+) or only describes what would be proposed (Phase 3). The config/foundry_studio.exs value is the primary mechanism; this field enables per-project override."
)
default(true)
end
# ── Notifications ─────────────────────────────────────────────────────────
attribute :notifications, Foundry.Manifest.NotificationConfig do
description(
"Notification channel configuration for the three required staleness conditions. All three keys are required by INV-010. Absence triggers a lint warning."
)
allow_nil?(true)
default(nil)
end
# ── Coverage ──────────────────────────────────────────────────────────────
attribute :coverage_gate, :boolean do
description(
"When true, a domain coverage score below 0.6 fails CI. Recommended: false for new projects, true before go-live."
)
default(false)
end
attribute :coverage_weights, Foundry.Manifest.CoverageWeights do
description(
"Override the default domain coverage formula weights. All five values must sum to 1.0."
)
default(nil)
end
# ── Data Retention ────────────────────────────────────────────────────────
attribute :data_retention, Foundry.Manifest.DataRetention do
description(
"Retention period overrides in days. Defaults are financial/regulated platform values."
)
default(nil)
end
# ── Context Exclusions ────────────────────────────────────────────────────
attribute :context_exclusions, {:array, :string} do
description(
"Module names excluded from mix foundry.context introspection. Use only as a temporary workaround for cyclic dependency or DSL loop issues. Each exclusion should have an issue reference in a comment in manifest.exs."
)
default([])
end
# ── Conditional Libraries ─────────────────────────────────────────────────
attribute :conditional_libraries, {:array, :atom} do
description(
"Optional ecosystem libraries present in this target platform. Foundry uses this list to enable/disable lint rules and scaffold operations. Valid values: :ash_money, :ash_state_machine, :ash_pyro, :fun_with_flags."
)
constraints(items: [one_of: [:ash_money, :ash_state_machine, :ash_pyro, :fun_with_flags]])
default([])
end
timestamps()
end
# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------
actions do
defaults([:read])
create :load do
description(
"Load and validate a manifest from a parsed keyword list. Called by Foundry.Manifest.Reader."
)
accept([
:project_name,
:domain_type,
:sensitive_resources,
:sensitive_resource_exemptions,
:approvers,
:approval_sla,
:auto_apply_structural,
:change_generation_enabled,
:notifications,
:coverage_gate,
:coverage_weights,
:data_retention,
:context_exclusions,
:conditional_libraries,
# Phase D — MCP / Agent config
:mcp_enabled,
:tidewave_scaffold,
:copilot_model
])
end
end
# ---------------------------------------------------------------------------
# Validations
# ---------------------------------------------------------------------------
validations do
validate Foundry.Manifest.Validations.RequiredApprovers do
description("sensitive_lead and compliance_officer must be present in approvers.")
message("approvers.sensitive_lead and approvers.compliance_officer are required")
end
validate Foundry.Manifest.Validations.ValidSensitiveExemptions do
description(
"All sensitive_resource_exemptions must reference modules in sensitive_resources."
)
message("sensitive_resource_exemptions references a module not in sensitive_resources")
end
validate Foundry.Manifest.Validations.ValidCoverageWeights do
description("coverage_weights values must sum to 1.0 ± 0.001.")
message("coverage_weights must sum to 1.0")
end
validate Foundry.Manifest.Validations.CldrBackendPresent do
description(
"If :ash_money is in conditional_libraries, a CLDR backend module must be discoverable in the project. Checked by inspecting compiled modules for a module using Cldr. The conditional_libraries check is performed inside the validator module — not in a where clause — because fragment/1 is not valid for Ash.DataLayer.Simple."
)
message("conditional_libraries includes :ash_money but no CLDR backend module found")
end
end
# ---------------------------------------------------------------------------
# Calculations
# ---------------------------------------------------------------------------
calculations do
calculate :has_notification_config, :boolean, expr(not is_nil(notifications)) do
description(
"Whether the manifest has any notification configuration declared. False triggers INV-010 lint warning."
)
end
# Module-based calculations for list membership — fragment/1 is Postgres-only
# and is not valid with Ash.DataLayer.Simple, which evaluates in Elixir.
calculate :ash_money_enabled,
:boolean,
{Foundry.Manifest.Calculations.LibraryEnabled, library: :ash_money} do
description("Whether :ash_money is declared in conditional_libraries.")
end
calculate :fun_with_flags_enabled,
:boolean,
{Foundry.Manifest.Calculations.LibraryEnabled, library: :fun_with_flags} do
description("Whether :fun_with_flags is declared in conditional_libraries.")
end
end
end
defmodule Foundry.Manifest.Calculations.LibraryEnabled do
@moduledoc """
Module-based calculation for `Foundry.Manifest`.
Returns true if the given library atom is present in `conditional_libraries`.
Used in place of SQL `fragment/1` expressions, which are not valid with
`Ash.DataLayer.Simple` (evaluated in Elixir, not Postgres).
## Usage
calculate :ash_money_enabled, :boolean,
Foundry.Manifest.Calculations.LibraryEnabled,
arguments: [library: :ash_money]
"""
use Ash.Resource.Calculation
@impl true
def calculate(records, opts, _context) do
library = Keyword.fetch!(opts, :library)
Enum.map(records, fn record ->
library in (record.conditional_libraries || [])
end)
end
end