defmodule Foundry.Proposals.Proposal do
@moduledoc """
A proposed change to a target project's codebase. The central resource for
Foundry's governance model.
## Storage
Proposals are stored as JSON files at `.foundry/proposals/prop_<id>.json`
in the target project's repository. This Ash resource validates and provides
typed access to proposal state — `Foundry.Proposals.ProposalStore` handles
reading and writing the JSON files (ADR-015).
The data layer is `Ash.DataLayer.Simple` — proposals are constructed in-memory
from the parsed JSON file. They are not stored in a database.
## State Machine
DRAFT → PENDING_REVIEW → APPROVED → APPLIED → COMMITTED
↓
REJECTED
Any state → STALE (when blob hashes no longer match — ADR-009)
PENDING_REVIEW → SUPERSEDED (when a newer proposal covers the same change)
## Approval Mechanics
`:sensitive` proposals require two distinct approvers (dual approval — ADR-014).
`:compliance` proposals require the compliance_officer and an ADR link.
`:behavioral` proposals require the domain_lead.
`:structural` proposals require any developer; may auto-apply if manifest configured.
## Paper Trail and Archival
Proposals are sensitive resources in Foundry's own manifest (they contain diffs
of potentially sensitive target platform code). AshPaperTrail and AshArchival
are both required (INV-011, INV-012).
## ADR
ADR-014 — Proposal Lifecycle.
ADR-009 — Concurrent Proposals (blob hash stale detection).
ADR-015 — Storage Model.
"""
use Ash.Resource,
domain: Foundry.Proposals,
data_layer: Ash.DataLayer.Simple,
authorizers: [Ash.Policy.Authorizer],
extensions: [
AshStateMachine,
AshPaperTrail.Resource,
AshArchival.Resource
]
# ---------------------------------------------------------------------------
# Attributes
# ---------------------------------------------------------------------------
attributes do
attribute :id, :string do
description("Proposal identifier. Format: prop_<ulid>. Used as the JSON filename stem.")
allow_nil?(false)
primary_key?(true)
generated?(true)
end
attribute :state, :atom do
description("Current lifecycle state. Managed by AshStateMachine — do not set directly.")
allow_nil?(false)
constraints(
one_of: [
:draft,
:pending_review,
:approved,
:applied,
:committed,
:rejected,
:stale,
:superseded
]
)
default(:draft)
end
attribute :change_class, :atom do
description(
"Classification of this proposal per ADR-005. Set by Foundry.Copilot.Classifier at proposal creation. Never set by the copilot engine directly — only via the classifier."
)
constraints(one_of: [:structural, :behavioral, :sensitive, :compliance])
allow_nil?(false)
end
attribute :operation, :string do
description(
"The Op.* module name that generated this proposal. E.g. 'Op.AddRule', 'Op.AddAttribute'."
)
allow_nil?(false)
end
attribute :operation_params, :map do
description("The parameters passed to the operation. Stored for re-execution on approval.")
end
attribute :diff, :string do
description(
"The unified diff output from Igniter dry_run. May be nil for proposals in DRAFT state before generation completes."
)
allow_nil?(true)
end
attribute :migration_diff, :string do
description(
"The unified diff of any ash_postgres migration generated alongside the code change. Nil when no migration is part of this proposal."
)
allow_nil?(true)
end
attribute :blob_hashes, :map do
description(
"Git blob hashes of all files affected by this proposal at creation time. Used for stale detection (ADR-009). A proposal is STALE when any hash no longer matches the current HEAD."
)
default(%{})
end
attribute :lint_result, :map do
description(
"Structured lint output from the pre-approval lint run. Nil until the proposal reaches PENDING_REVIEW."
)
allow_nil?(true)
end
attribute :impact_analysis, :map do
description(
"Deterministic impact analysis computed by Foundry.Copilot.ImpactAnalyzer. Nil until the proposal reaches PENDING_REVIEW."
)
allow_nil?(true)
end
attribute :adr_link, :string do
description(
"ADR identifier linked to this proposal. Required for :compliance proposals (validated before PENDING_REVIEW transition). Optional for other classes."
)
allow_nil?(true)
end
attribute :requester, :string do
description("Email address of the user who created this proposal.")
allow_nil?(false)
end
attribute :approval_slot_1, :map do
description(
"First approval slot. For :sensitive proposals: must be filled by sensitive_lead. For :behavioral: domain_lead. For :structural: any developer."
)
allow_nil?(true)
end
attribute :approval_slot_2, :map do
description(
"Second approval slot. Only used for :sensitive proposals (dual approval). Must be a different person from approval_slot_1. May be filled by domain_lead, platform_lead, or compliance_officer per manifest."
)
allow_nil?(true)
end
attribute :submitted_at, :utc_datetime do
description("When the proposal transitioned from DRAFT to PENDING_REVIEW.")
allow_nil?(true)
end
attribute :applied_at, :utc_datetime do
description("When Igniter applied the change to the filesystem.")
allow_nil?(true)
end
attribute :committed_at, :utc_datetime do
description("When the resulting git commit was created.")
allow_nil?(true)
end
attribute :git_commit_sha, :string do
description("The SHA of the git commit created on apply. Nil until COMMITTED state.")
allow_nil?(true)
end
attribute :rejection_reason, :string do
description(
"Human-provided reason when a proposal is REJECTED. Required on the reject action."
)
allow_nil?(true)
end
attribute :stale_reason, :string do
description(
"Which file(s) changed to make this proposal STALE. Populated by the stale detection job."
)
allow_nil?(true)
end
attribute :superseded_by, :string do
description(
"The proposal_id of the proposal that superseded this one. Nil unless state is :superseded."
)
allow_nil?(true)
end
timestamps()
end
# ---------------------------------------------------------------------------
# State Machine
# ---------------------------------------------------------------------------
state_machine do
initial_states([:draft])
default_initial_state(:draft)
transitions do
transition(:submit, from: :draft, to: :pending_review)
transition(:approve, from: :pending_review, to: :approved)
transition(:apply, from: :approved, to: :applied)
transition(:commit, from: :applied, to: :committed)
transition(:reject, from: :pending_review, to: :rejected)
transition(:mark_stale, from: [:draft, :pending_review, :approved, :applied], to: :stale)
transition(:supersede, from: :pending_review, to: :superseded)
end
end
# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------
actions do
defaults([:read])
create :create_draft do
description(
"Create a new proposal in DRAFT state. Called by Foundry.Copilot.Engine after generating a diff."
)
accept([
:change_class,
:operation,
:operation_params,
:diff,
:migration_diff,
:blob_hashes,
:requester,
:adr_link
])
end
update :submit do
description(
"Submit a DRAFT proposal for review. Validates diff is present, runs lint + impact analysis, checks :compliance ADR link requirement."
)
change(transition_state(:pending_review))
change(set_attribute(:submitted_at, &DateTime.utc_now/0))
validate(present(:diff), message: "cannot submit a proposal without a diff")
validate(Foundry.Proposals.Validations.ComplianceAdrLinkPresent)
end
update :approve do
description(
"Record an approval. The approver identity and role are validated against the manifest by AuthorizedApprover policy. For :sensitive proposals, state only advances to :approved when both slots are filled. A requester cannot approve their own proposal."
)
# No accept list — slot assignment is entirely driven by the RecordApproval change,
# which reads the actor from action context and sets the correct slot (1 or 2).
# Accepting :approval_slot_1 / :approval_slot_2 directly would allow a single caller
# to fill both slots in one request, bypassing the two-distinct-humans constraint
# from ADR-014 §Dual Approval Mechanics.
change(Foundry.Proposals.Changes.RecordApproval)
change(Foundry.Proposals.Changes.AdvanceOnDualApproval)
end
update :apply do
description(
"Execute the Igniter operation. Runs Foundry.Operations.run/2 with dry_run: false. Sets applied_at."
)
change(transition_state(:applied))
change(set_attribute(:applied_at, &DateTime.utc_now/0))
end
update :commit do
description(
"Record the git commit SHA after Igniter apply succeeds. Advances to COMMITTED."
)
accept([:git_commit_sha])
change(transition_state(:committed))
change(set_attribute(:committed_at, &DateTime.utc_now/0))
end
update :reject do
description("Reject the proposal. rejection_reason is required.")
accept([:rejection_reason])
change(transition_state(:rejected))
validate(present(:rejection_reason), message: "rejection reason is required")
end
update :mark_stale do
description(
"Mark the proposal as stale when blob hash check detects an underlying file has changed."
)
accept([:stale_reason])
change(transition_state(:stale))
end
update :supersede do
description("Mark this proposal as superseded by a newer one.")
accept([:superseded_by])
change(transition_state(:superseded))
validate(present(:superseded_by), message: "superseded_by proposal_id is required")
end
end
# ---------------------------------------------------------------------------
# Policies
# ---------------------------------------------------------------------------
policies do
policy action(:read) do
description(
"DRAFT proposals are only visible to the requester. PENDING_REVIEW and later are visible to all project users."
)
authorize_if(Foundry.Proposals.Policies.ProposalVisibility)
end
policy action(:approve) do
description(
"Approver must be a named approver in the manifest with the correct role for this proposal's change_class. A requester cannot approve their own proposal."
)
authorize_if(Foundry.Proposals.Policies.AuthorizedApprover)
end
policy action(:apply) do
description(
":structural auto-apply is triggered by the approval action. All other classes require a separate deliberate Apply action by an authorized approver."
)
authorize_if(Foundry.Proposals.Policies.AuthorizedApply)
end
end
# ---------------------------------------------------------------------------
# Calculations
# ---------------------------------------------------------------------------
calculations do
calculate :is_stale, :boolean, expr(state == :stale) do
description("Whether this proposal has been invalidated by an underlying file change.")
end
calculate :awaiting_second_approval,
:boolean,
expr(
change_class == :sensitive and not is_nil(approval_slot_1) and
is_nil(approval_slot_2)
) do
description(
"Whether this :sensitive proposal has one approval and is waiting for the second."
)
end
calculate :fully_approved, :boolean, Foundry.Proposals.Calculations.FullyApproved do
description(
"Whether all required approvals for this proposal's change_class have been recorded."
)
end
end
end
# ---------------------------------------------------------------------------
# Embedded resources
# ---------------------------------------------------------------------------
defmodule Foundry.Proposals.ApprovalSlot do
@moduledoc """
Records a single approval event for a proposal.
Slots are immutable once filled — an approver cannot change their approval.
Revocation requires rejecting the proposal and creating a new one.
See: ADR-014 §Dual Approval Mechanics.
"""
use Ash.Resource, data_layer: :embedded
attributes do
attribute :approver, :string do
description("Email address of the approver.")
allow_nil?(false)
end
attribute :approver_role, :atom do
description("The manifest role under which this approval was granted.")
constraints(
one_of: [:sensitive_lead, :domain_lead, :platform_lead, :compliance_officer, :developer]
)
allow_nil?(false)
end
attribute :approved_at, :utc_datetime do
description("Timestamp of the approval action.")
allow_nil?(false)
end
end
end
defmodule Foundry.Proposals.BlobHash do
@moduledoc """
A git blob hash for a single file at the time a proposal was created.
Used for stale detection per ADR-009.
If the file's current blob hash at HEAD differs from this value, the proposal is STALE.
"""
use Ash.Resource, data_layer: :embedded
attributes do
attribute :file_path, :string do
description("Relative path from project root. E.g. 'lib/my_app/finance/wallet.ex'.")
allow_nil?(false)
end
attribute :blob_hash, :string do
description(
"Git blob hash (SHA-256 format: 'sha256:abc...'). Computed at proposal creation."
)
allow_nil?(false)
end
end
end
defmodule Foundry.Proposals.LintResult do
@moduledoc """
Structured output from the pre-approval lint run.
Populated when a proposal transitions from DRAFT to PENDING_REVIEW.
A proposal with :error severity violations cannot advance past PENDING_REVIEW.
"""
use Ash.Resource, data_layer: :embedded
attributes do
attribute :passed, :boolean do
description("True if no :error severity violations were found.")
allow_nil?(false)
end
attribute :violations, :map do
description(
"All violations found. May include :warning and :info severity items even when passed is true."
)
default(%{})
end
attribute :ran_at, :utc_datetime do
description("When the lint run completed.")
allow_nil?(false)
end
end
end
defmodule Foundry.Proposals.LintViolation do
@moduledoc "A single lint violation from the pre-approval lint run."
use Ash.Resource, data_layer: :embedded
attributes do
attribute :rule_id, :atom do
description("The lint rule identifier. E.g. :missing_description, :missing_paper_trail.")
allow_nil?(false)
end
attribute :severity, :atom do
description(
"Severity level. :error blocks submission. :warning and :info are informational."
)
constraints(one_of: [:error, :warning, :info])
allow_nil?(false)
end
attribute :message, :string do
description("Human-readable description of the violation.")
allow_nil?(false)
end
attribute :file_path, :string do
description(
"The file path where the violation was detected. May be nil for manifest-level violations."
)
allow_nil?(true)
end
attribute :module, :string do
description("The module name where the violation was detected.")
allow_nil?(true)
end
end
end
defmodule Foundry.Proposals.ImpactAnalysis do
@moduledoc """
Deterministic impact analysis computed by Foundry.Copilot.ImpactAnalyzer.
Not LLM-generated — derived from static analysis of the diff and the project's
context graph. See: ADR-012 §Impact Tab.
"""
use Ash.Resource, data_layer: :embedded
attributes do
attribute :affected_modules, {:array, :string} do
description("All modules directly or transitively affected by this change.")
default([])
end
attribute :affected_tests, {:array, :string} do
description("Test modules that reference affected_modules and should be re-run.")
default([])
end
attribute :migration_required, :boolean do
description("Whether this proposal includes a database migration.")
default(false)
end
attribute :migration_is_reversible, :boolean do
description("Whether the generated migration includes a down/rollback path.")
allow_nil?(true)
end
attribute :touches_sensitive_resources, :boolean do
description("Whether any affected module is in manifest.sensitive_resources.")
default(false)
end
attribute :compliance_requirements_affected, {:array, :string} do
description("RG-* requirement IDs whose implementing modules are in affected_modules.")
default([])
end
attribute :computed_at, :utc_datetime do
description("When this analysis was computed.")
allow_nil?(false)
end
end
end