feat(orchestrator): harden consortium starter pipeline — FfcSchematic RPCs, validation, wire encoding

Rewrites the schematic deployment pipeline from dead SchematicsService RPCs
(ForkSchematic/CreateDeploymentBinding) to the actual FfcSchematicService flow
(Create→Validate→Approve→Publish→Realize). Adds template schema validation,
variable resolution hardening, wire encoding, and centralized realization status.

New modules:
- SchematicTemplate.Schema — 7-section structural + cross-section validation
- SchematicTemplate.VariableResolver — placeholder resolution with param checks
- SchematicTemplate.WireEncoder — resolved template → FfcSchematic wire format
- SchematicClient.Behaviour — callback definitions for testability
- FfcPipeline — 12-step deploy orchestrator with step-level error reporting
- RealizationStatus — centralized status classification and display helpers

Changes:
- SchematicClient: removed fork/bind RPCs, added FfcSchematic RPCs
- RealizationPoller: delegates to RealizationStatus, persists snapshots to DB
- GuildSchematic: expanded status enum, added founding_override_expires_at
- Realization LiveView: uses RealizationStatus for all status logic
- Schematic LiveView: replaced dead flow with FfcPipeline.deploy/2

52 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-05-16 10:33:13 -04:00
parent c9800c98e2
commit 50c488b92b
20 changed files with 1585 additions and 178 deletions

View file

@ -16,6 +16,7 @@ defmodule Guildhall.OpsDb.GuildSchematic do
field :status, :string, default: "pending"
field :customization_params, :map, default: %{}
field :realization_snapshot, :map, default: %{}
field :founding_override_expires_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
@ -32,10 +33,11 @@ defmodule Guildhall.OpsDb.GuildSchematic do
:realization_id,
:status,
:customization_params,
:realization_snapshot
:realization_snapshot,
:founding_override_expires_at
])
|> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version])
|> validate_inclusion(:status, ~w(pending forked binding_created realizing realized partially_realized failed))
|> validate_inclusion(:status, ~w(pending draft validated approved published realizing realized partially_realized failed))
|> foreign_key_constraint(:guild_id)
|> unique_constraint([:schematic_name, :schematic_version])
end

View file

@ -0,0 +1,9 @@
defmodule Guildhall.OpsDb.Repo.Migrations.AlterGuildSchematicsStatus do
use Ecto.Migration
def change do
alter table(:guild_schematics) do
add :founding_override_expires_at, :utc_datetime_usec
end
end
end

View file

@ -0,0 +1,182 @@
defmodule Guildhall.Orchestrator.FfcPipeline do
@moduledoc false
require Logger
alias Guildhall.Orchestrator.SchematicTemplate
alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder}
alias Guildhall.Orchestrator.RealizationPoller
alias Guildhall.OpsDb.GuildSchematics
defstruct [
:guild,
:guild_type,
:template,
:resolved,
:manifest_yaml,
:files,
:founding_overrides,
:schematic_name,
:schematic_version,
:guild_schematic,
:realization_id
]
def deploy(guild, opts \\ []) do
guild_type = guild.guild_type
version = Keyword.get(opts, :version, "1.0.0")
schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}")
params = %{
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
"guild_slug" => guild.slug,
"guild_name" => guild.slug,
"registrant_did" => guild.registrant_did
}
state = %__MODULE__{
guild: guild,
guild_type: guild_type,
schematic_name: schematic_name,
schematic_version: version
}
with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end),
{:ok, state} <- step(:validate_template, state, fn -> validate_template(state) end),
{:ok, state} <- step(:resolve_variables, state, fn -> resolve_variables(state, params) end),
{:ok, state} <- step(:validate_resolved, state, fn -> validate_resolved(state) end),
{:ok, state} <- step(:encode_wire, state, fn -> encode_wire(state) end),
{:ok, state} <- step(:create_schematic, state, fn -> create_schematic(state) end),
{:ok, state} <- step(:validate_schematic, state, fn -> validate_schematic(state) end),
{:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state) end),
{:ok, state} <- step(:publish_schematic, state, fn -> publish_schematic(state) end),
{:ok, state} <- step(:realize_schematic, state, fn -> realize_schematic(state) end),
{:ok, state} <- step(:create_db_record, state, fn -> create_db_record(state) end),
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do
{:ok, state}
end
end
defp step(name, _state, fun) do
case fun.() do
{:ok, new_state} -> {:ok, new_state}
{:error, reason} -> {:error, {name, reason}}
end
end
defp load_template(%{guild_type: guild_type} = state) do
case SchematicTemplate.load_template(guild_type) do
{:ok, template} -> {:ok, %{state | template: template}}
error -> error
end
end
defp validate_template(%{template: template} = state) do
case Schema.validate(template) do
:ok -> {:ok, state}
error -> error
end
end
defp resolve_variables(%{template: template} = state, params) do
case VariableResolver.resolve(template, params) do
{:ok, resolved} -> {:ok, %{state | resolved: resolved}}
error -> error
end
end
defp validate_resolved(%{resolved: resolved} = state) do
case Schema.validate_resolved(resolved) do
:ok -> {:ok, state}
error -> error
end
end
defp encode_wire(%{resolved: resolved, schematic_name: name, schematic_version: version} = state) do
hub_did = Application.get_env(:guildhall_orchestrator, :hub_did, "did:web:guildhouse.dev")
case WireEncoder.encode(resolved, name, version, hub_did: hub_did) do
{:ok, manifest_yaml, files} ->
overrides = WireEncoder.extract_founding_overrides(resolved)
if map_size(overrides) > 0 do
Logger.warning("Founding overrides active: #{inspect(overrides)}")
end
{:ok, %{state | manifest_yaml: manifest_yaml, files: files, founding_overrides: overrides}}
error ->
error
end
end
defp create_schematic(%{schematic_name: name, schematic_version: version, manifest_yaml: manifest, files: files} = state) do
case client().create_ffc_schematic(name, version, manifest, files) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp validate_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().validate_ffc_schematic(name, version) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp approve_schematic(%{schematic_name: name, schematic_version: version} = state) do
Logger.info("STUB: orgops approve for #{name}@#{version} — signing deferred to governed shell")
{:ok, state}
end
defp publish_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().publish_ffc_schematic(name, version) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp realize_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().realize_ffc_schematic(name, version) do
{:ok, response} -> {:ok, %{state | realization_id: response.realization_id}}
error -> error
end
end
defp create_db_record(%{guild: guild} = state) do
override_ttl_days =
Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90)
overrides = state.founding_overrides || %{}
expires_at =
if map_size(overrides) > 0 do
DateTime.utc_now() |> DateTime.add(override_ttl_days * 86_400, :second) |> DateTime.truncate(:microsecond)
end
attrs = %{
guild_id: guild.id,
template_name: "#{state.guild_type}-founding",
schematic_name: state.schematic_name,
schematic_version: state.schematic_version,
realization_id: state.realization_id,
status: "realizing",
customization_params: overrides,
founding_override_expires_at: expires_at
}
case GuildSchematics.create(attrs) do
{:ok, gs} -> {:ok, %{state | guild_schematic: gs}}
error -> error
end
end
defp start_poller(%{realization_id: realization_id, guild: guild} = state) do
RealizationPoller.watch(realization_id, guild.slug)
{:ok, state}
end
defp client do
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
end
end

View file

@ -2,7 +2,8 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
use GenServer
require Logger
alias Guildhall.Orchestrator.SchematicClient
alias Guildhall.Orchestrator.RealizationStatus
alias Guildhall.OpsDb.GuildSchematics
@poll_interval_ms 5_000
@ -43,9 +44,11 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
def handle_info(:poll, state) do
new_watches =
Enum.reduce(state.watches, state.watches, fn {realization_id, guild_slug}, acc ->
case SchematicClient.get_realization_status(realization_id) do
case client().get_realization_status(realization_id) do
{:ok, response} ->
snapshot = build_snapshot(response)
snapshot = RealizationStatus.build_snapshot(response)
persist_snapshot(realization_id, snapshot)
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
@ -53,7 +56,7 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
{:realization_update, snapshot}
)
if terminal_status?(response.overall_status) do
if RealizationStatus.terminal_status?(response.overall_status) do
Map.delete(acc, realization_id)
else
acc
@ -74,18 +77,14 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
defp schedule_poll, do: Process.send_after(self(), :poll, @poll_interval_ms)
defp build_snapshot(response) do
%{
overall_status: to_string(response.overall_status),
sections:
Enum.map(response.per_section, fn s ->
%{name: s.section_name, status: s.status, message: s.message}
end)
}
defp persist_snapshot(realization_id, snapshot) do
case Guildhall.OpsDb.Repo.get_by(Guildhall.OpsDb.GuildSchematic, realization_id: realization_id) do
nil -> :ok
gs -> GuildSchematics.update_realization_snapshot(gs, snapshot)
end
end
defp terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED), do: true
defp terminal_status?(:FFC_SCHEMATIC_STATUS_ARCHIVED), do: true
defp terminal_status?(:FFC_SCHEMATIC_STATUS_WITHDRAWN), do: true
defp terminal_status?(_), do: false
defp client do
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
end
end

View file

@ -0,0 +1,93 @@
defmodule Guildhall.Orchestrator.RealizationStatus do
@moduledoc false
require Logger
@terminal_statuses ~w(
FFC_SCHEMATIC_STATUS_REALIZED
FFC_SCHEMATIC_STATUS_ARCHIVED
FFC_SCHEMATIC_STATUS_WITHDRAWN
)
def build_snapshot(response) do
%{
overall_status: to_string(response.overall_status),
sections:
Enum.map(response.per_section, fn s ->
%{name: s.section_name, status: s.status, message: s.message}
end)
}
end
def terminal_status?(status) when is_atom(status), do: to_string(status) in @terminal_statuses
def terminal_status?(status) when is_binary(status), do: status in @terminal_statuses
def terminal_status?(_), do: false
def classify_section("succeeded"), do: {:succeeded, "Succeeded"}
def classify_section("in_progress"), do: {:in_progress, "In progress"}
def classify_section("failed"), do: {:failed, "Failed"}
def classify_section("pending"), do: {:pending, "Pending"}
def classify_section("skipped:no_change"), do: {:skipped, "Skipped (no change)"}
def classify_section("skipped:not_implemented"), do: {:skipped, "Skipped (stub)"}
def classify_section("skipped:upstream_unavailable:" <> svc), do: {:skipped, "Skipped (#{svc} unavailable)"}
def classify_section("skipped:" <> _reason), do: {:skipped, "Skipped"}
def classify_section(unknown) do
Logger.warning("Unknown reconciler section status: #{inspect(unknown)}")
{:pending, to_string(unknown)}
end
def derive_overall(snapshot) do
sections = Map.get(snapshot, :sections, Map.get(snapshot, "sections", []))
statuses =
Enum.map(sections, fn s ->
raw = s[:status] || s["status"] || "pending"
{class, _label} = classify_section(raw)
class
end)
cond do
statuses == [] -> :pending
Enum.all?(statuses, &(&1 in [:succeeded, :skipped])) -> :realized
Enum.any?(statuses, &(&1 == :failed)) and Enum.any?(statuses, &(&1 in [:succeeded, :skipped])) -> :partially_realized
Enum.any?(statuses, &(&1 == :failed)) -> :failed
Enum.any?(statuses, &(&1 == :in_progress)) -> :in_progress
true -> :pending
end
end
def display_status(:realized), do: "Realized"
def display_status(:partially_realized), do: "Partially realized"
def display_status(:failed), do: "Failed"
def display_status(:in_progress), do: "In progress"
def display_status(:pending), do: "Pending"
def overall_css_class(:realized), do: "bg-emerald-100 text-emerald-700"
def overall_css_class(:partially_realized), do: "bg-amber-100 text-amber-700"
def overall_css_class(:failed), do: "bg-red-100 text-red-700"
def overall_css_class(:in_progress), do: "bg-amber-100 text-amber-700"
def overall_css_class(:pending), do: "bg-zinc-100 text-zinc-700"
def section_icon_class("succeeded"), do: "text-emerald-600"
def section_icon_class("in_progress"), do: "text-amber-600"
def section_icon_class("failed"), do: "text-red-600"
def section_icon_class("pending"), do: "text-zinc-400"
def section_icon_class("skipped:" <> _), do: "text-sky-500"
def section_icon_class(_), do: "text-zinc-400"
def section_symbol("succeeded"), do: ""
def section_symbol("in_progress"), do: ""
def section_symbol("failed"), do: ""
def section_symbol("skipped:" <> _), do: ""
def section_symbol(_), do: ""
def section_display_status(status) do
{_class, label} = classify_section(status)
label
end
def all_known_statuses do
~w(succeeded in_progress failed pending skipped:no_change skipped:not_implemented skipped:upstream_unavailable:service)
end
end

View file

@ -1,95 +1,88 @@
defmodule Guildhall.Orchestrator.SchematicClient do
@moduledoc false
alias Schematic.V1.{
SchematicsService.Stub,
ForkSchematicRequest,
TemplateOperation,
CreateDeploymentBindingRequest,
GetDeploymentBindingRequest
}
@behaviour Guildhall.Orchestrator.SchematicClient.Behaviour
alias Schematic.V1.RealizeFfcSchematicRequest
alias Schematic.V1.GetRealizationStatusRequest
alias Schematic.V1.FfcSchematicService.Stub
alias Schematic.V1.FfcSchematicFile
def fork_schematic(source_name, source_version, new_name, new_version, operations) do
ops =
Enum.map(operations, fn op ->
%TemplateOperation{
op_type: op[:op_type] || "replace",
path: op[:path],
content: op[:content] || ""
}
@impl true
def create_ffc_schematic(name, version, manifest_yaml, files) do
proto_files =
Enum.map(files, fn {path, content} ->
%FfcSchematicFile{path: path, content: content}
end)
request = %ForkSchematicRequest{
source_name: source_name,
source_version: source_version,
new_name: new_name,
new_version: new_version,
operations: ops
request = %Schematic.V1.CreateFfcSchematicRequest{
name: name,
version: version,
manifest_yaml: manifest_yaml,
files: proto_files
}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.fork_schematic(channel, request) do
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.create_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def create_deployment_binding(schematic_name, version, pipeline_name, target_env) do
request = %CreateDeploymentBindingRequest{
schematic_name: schematic_name,
schematic_version: version,
pipeline_name: pipeline_name,
target_env: target_env
@impl true
def validate_ffc_schematic(name, version) do
request = %Schematic.V1.ValidateFfcSchematicRequest{
name: name,
version: version
}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.create_deployment_binding(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def get_deployment_binding(binding_id) do
request = %GetDeploymentBindingRequest{binding_id: binding_id}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.get_deployment_binding(channel, request) do
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.validate_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
if response.valid do
{:ok, response}
else
{:error, {:validation_failed, response.results}}
end
end
end
@impl true
def publish_ffc_schematic(name, version) do
request = %Schematic.V1.PublishFfcSchematicRequest{
name: name,
version: version
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.publish_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def realize_ffc_schematic(name, version) do
request = %RealizeFfcSchematicRequest{name: name, version: version}
request = %Schematic.V1.RealizeFfcSchematicRequest{name: name, version: version}
with {:ok, channel} <- connect_ffc(),
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.realize_ffc_schematic(channel, request) do
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.realize_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def get_realization_status(realization_id) do
request = %GetRealizationStatusRequest{realization_id: realization_id}
request = %Schematic.V1.GetRealizationStatusRequest{realization_id: realization_id}
with {:ok, channel} <- connect_ffc(),
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.get_realization_status(channel, request) do
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.get_realization_status(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
defp connect_schematic do
url =
Application.get_env(:guildhall_orchestrator, :schematic_service_url, "localhost:9091")
GRPC.Stub.connect(url)
end
defp connect_ffc do
defp connect do
url =
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")

View file

@ -0,0 +1,18 @@
defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do
@moduledoc false
@callback create_ffc_schematic(String.t(), String.t(), binary(), [{String.t(), binary()}]) ::
{:ok, map()} | {:error, term()}
@callback validate_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback publish_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback realize_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback get_realization_status(String.t()) ::
{:ok, map()} | {:error, term()}
end

View file

@ -25,13 +25,11 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do
rendered_template
|> Map.drop(["meta"])
|> Enum.flat_map(fn {section, content} ->
yaml = to_yaml_string(content)
[
%{
op_type: "replace",
path: "#{section}.yaml",
content: yaml
path: "#{section}.json",
content: Jason.encode!(content)
}
]
end)
@ -58,12 +56,6 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do
defp deep_substitute(value, _params), do: value
defp to_yaml_string(map) when is_map(map) do
Jason.encode!(map)
end
defp to_yaml_string(other), do: to_string(other)
defp template_dir do
Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates")
end

View file

@ -0,0 +1,387 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.Schema do
@moduledoc false
@known_sections ~w(meta trust_domain identity_authority members infrastructure ceremonies federation_peers attestation)
@valid_ceremony_types ~w(single_approval multi_party)
@valid_roles ~w(master journeyman apprentice)
@valid_federation_modes ~w(hub_spoke mesh)
@valid_trust_levels ~w(federated local)
@valid_providers ~w(keycloak)
@tier_requirements %{
1 => %{require_tpm: false, require_secure_boot: false, mfa_required: false},
2 => %{require_tpm: false, require_secure_boot: false, mfa_required: true},
3 => %{require_tpm: true, require_secure_boot: true, mfa_required: true, hardware_credential_required: true}
}
@type_to_federation %{
"msp" => "hub_spoke",
"isv" => "hub_spoke",
"nsp" => "mesh"
}
def validate(template) when is_map(template) do
with :ok <- check_unknown_sections(template),
:ok <- validate_meta(template["meta"]),
:ok <- validate_trust_domain(template["trust_domain"]),
:ok <- validate_identity_authority(template["identity_authority"]),
:ok <- validate_members(template["members"]),
:ok <- validate_infrastructure(template["infrastructure"]),
:ok <- validate_ceremonies(template["ceremonies"]),
:ok <- validate_federation_peers(template["federation_peers"]),
:ok <- validate_attestation(template["attestation"]),
:ok <- validate_cross_section(template) do
:ok
end
end
def validate(nil), do: {:error, {:missing_section, "template is nil"}}
def validate(_), do: {:error, {:invalid_type, "template must be a map"}}
def validate_resolved(template) when is_map(template) do
with :ok <- validate_resolved_values(template) do
:ok
end
end
# --- Section validators ---
defp check_unknown_sections(template) do
unknown = Map.keys(template) -- @known_sections
if unknown == [], do: :ok, else: {:error, {:unknown_sections, unknown}}
end
defp validate_meta(nil), do: {:error, {:missing_section, "meta"}}
defp validate_meta(meta) when is_map(meta) do
with :ok <- require_string(meta, "template_name", "meta"),
:ok <- require_string(meta, "source_schematic", "meta"),
:ok <- require_string(meta, "source_version", "meta") do
:ok
end
end
defp validate_trust_domain(nil), do: {:error, {:missing_section, "trust_domain"}}
defp validate_trust_domain(td) when is_map(td) do
with :ok <- require_string(td, "spiffe_trust_domain", "trust_domain"),
:ok <- require_integer_in(td, "attestation_tier", 1..3, "trust_domain") do
:ok
end
end
defp validate_identity_authority(nil), do: {:error, {:missing_section, "identity_authority"}}
defp validate_identity_authority(ia) when is_map(ia) do
with :ok <- require_string(ia, "provider", "identity_authority"),
:ok <- require_inclusion(ia, "provider", @valid_providers, "identity_authority"),
:ok <- require_string(ia, "url", "identity_authority"),
:ok <- require_string(ia, "realm", "identity_authority"),
:ok <- optional_inclusion(ia, "trust_level", @valid_trust_levels, "identity_authority"),
:ok <- optional_boolean(ia, "mfa_required", "identity_authority"),
:ok <- optional_boolean(ia, "hardware_credential_required", "identity_authority") do
:ok
end
end
defp validate_members(nil), do: {:error, {:missing_section, "members"}}
defp validate_members(m) when is_map(m) do
with :ok <- require_string(m, "founding_master_did", "members"),
:ok <- require_string_list(m, "initial_roles", "members"),
:ok <- validate_roles(m["initial_roles"], "members.initial_roles") do
:ok
end
end
defp validate_infrastructure(nil), do: {:error, {:missing_section, "infrastructure"}}
defp validate_infrastructure(infra) when is_map(infra) do
with :ok <- require_integer_in(infra, "compute_attestation_tier", 1..3, "infrastructure"),
:ok <- optional_boolean(infra, "wireguard_tunnel", "infrastructure"),
:ok <- optional_boolean(infra, "vpp_dataplane", "infrastructure") do
:ok
end
end
defp validate_ceremonies(nil), do: {:error, {:missing_section, "ceremonies"}}
defp validate_ceremonies(c) when is_map(c) do
with :ok <- require_map(c, "code_change", "ceremonies"),
:ok <- validate_ceremony_rule(c["code_change"], "ceremonies.code_change"),
:ok <- require_map(c, "governance_change", "ceremonies"),
:ok <- validate_ceremony_rule(c["governance_change"], "ceremonies.governance_change") do
:ok
end
end
defp validate_ceremony_rule(rule, path) when is_map(rule) do
with :ok <- require_string(rule, "type", path),
:ok <- require_inclusion(rule, "type", @valid_ceremony_types, path),
:ok <- require_string_list(rule, "eligible_roles", path),
:ok <- validate_roles(rule["eligible_roles"], "#{path}.eligible_roles"),
:ok <- require_positive_integer(rule, "quorum", path),
:ok <- validate_founding_override(rule, path) do
:ok
end
end
defp validate_ceremony_rule(_, path), do: {:error, {:invalid_type, "#{path} must be a map"}}
defp validate_founding_override(rule, path) do
case rule["founding_override"] do
nil ->
:ok
override when is_integer(override) ->
cond do
rule["type"] != "multi_party" ->
{:error, {:invalid_value, "#{path}.founding_override is only valid on multi_party ceremonies"}}
override < 1 ->
{:error, {:invalid_value, "#{path}.founding_override must be >= 1"}}
override >= rule["quorum"] ->
{:error, {:invalid_value, "#{path}.founding_override must be less than quorum (#{rule["quorum"]})"}}
true ->
:ok
end
_ ->
{:error, {:invalid_type, "#{path}.founding_override must be an integer"}}
end
end
defp validate_federation_peers(nil), do: {:error, {:missing_section, "federation_peers"}}
defp validate_federation_peers(fp) when is_map(fp) do
with :ok <- require_string(fp, "mode", "federation_peers"),
:ok <- require_inclusion(fp, "mode", @valid_federation_modes, "federation_peers"),
:ok <- require_string(fp, "hub_trust_domain", "federation_peers") do
:ok
end
end
defp validate_attestation(nil), do: {:error, {:missing_section, "attestation"}}
defp validate_attestation(att) when is_map(att) do
with :ok <- require_integer_in(att, "tier", 1..3, "attestation"),
:ok <- optional_boolean(att, "require_tpm", "attestation"),
:ok <- optional_boolean(att, "require_secure_boot", "attestation") do
:ok
end
end
# --- Cross-section validators ---
defp validate_cross_section(template) do
with :ok <- validate_tier_consistency(template),
:ok <- validate_tier_requirements(template),
:ok <- validate_federation_type(template),
:ok <- validate_mesh_wireguard(template) do
:ok
end
end
defp validate_tier_consistency(template) do
td_tier = get_in(template, ["trust_domain", "attestation_tier"])
infra_tier = get_in(template, ["infrastructure", "compute_attestation_tier"])
att_tier = get_in(template, ["attestation", "tier"])
tiers = [td_tier, infra_tier, att_tier] |> Enum.reject(&is_nil/1) |> Enum.uniq()
if length(tiers) <= 1 do
:ok
else
{:error, {:tier_mismatch, "attestation tiers must be consistent: trust_domain=#{td_tier}, infrastructure=#{infra_tier}, attestation=#{att_tier}"}}
end
end
defp validate_tier_requirements(template) do
tier = get_in(template, ["attestation", "tier"])
reqs = Map.get(@tier_requirements, tier)
if is_nil(reqs) do
:ok
else
errors =
[]
|> check_tier_bool(template, ["attestation", "require_tpm"], reqs[:require_tpm], "attestation.require_tpm")
|> check_tier_bool(template, ["attestation", "require_secure_boot"], reqs[:require_secure_boot], "attestation.require_secure_boot")
|> check_tier_bool(template, ["identity_authority", "mfa_required"], reqs[:mfa_required], "identity_authority.mfa_required")
|> maybe_check_tier_bool(template, ["identity_authority", "hardware_credential_required"], reqs[:hardware_credential_required], "identity_authority.hardware_credential_required")
case errors do
[] -> :ok
errs -> {:error, {:tier_requirement_mismatch, Enum.reverse(errs)}}
end
end
end
defp check_tier_bool(errors, template, path, expected, label) do
actual = get_in(template, path)
if not is_nil(actual) and actual != expected do
["#{label}: expected #{expected} for tier #{get_in(template, ["attestation", "tier"])}, got #{actual}" | errors]
else
errors
end
end
defp maybe_check_tier_bool(errors, template, path, nil, _label), do: errors |> check_tier_bool(template, path, nil, "")
defp maybe_check_tier_bool(errors, template, path, expected, label), do: check_tier_bool(errors, template, path, expected, label)
defp validate_federation_type(template) do
template_name = get_in(template, ["meta", "template_name"]) || ""
mode = get_in(template, ["federation_peers", "mode"])
guild_type =
cond do
String.contains?(template_name, "msp") -> "msp"
String.contains?(template_name, "isv") -> "isv"
String.contains?(template_name, "nsp") -> "nsp"
true -> nil
end
expected = Map.get(@type_to_federation, guild_type)
if is_nil(expected) or is_nil(mode) or mode == expected do
:ok
else
{:error, {:federation_mode_mismatch, "#{guild_type} templates must use #{expected} federation, got #{mode}"}}
end
end
defp validate_mesh_wireguard(template) do
mode = get_in(template, ["federation_peers", "mode"])
wg = get_in(template, ["infrastructure", "wireguard_tunnel"])
cond do
mode == "mesh" and wg != true ->
{:error, {:mesh_requires_wireguard, "mesh federation mode requires infrastructure.wireguard_tunnel = true"}}
true ->
:ok
end
end
# --- Resolved value validators (post-substitution) ---
defp validate_resolved_values(template) do
errors =
[]
|> validate_did_format(get_in(template, ["members", "founding_master_did"]), "members.founding_master_did")
|> validate_domain_format(get_in(template, ["trust_domain", "spiffe_trust_domain"]), "trust_domain.spiffe_trust_domain")
|> validate_slug_format(get_in(template, ["identity_authority", "client_prefix"]), "identity_authority.client_prefix")
case errors do
[] -> :ok
errs -> {:error, {:invalid_resolved_values, Enum.reverse(errs)}}
end
end
defp validate_did_format(errors, nil, _path), do: errors
defp validate_did_format(errors, value, path) when is_binary(value) do
if String.starts_with?(value, "did:") do
errors
else
["#{path}: must be a DID (got #{inspect(value)})" | errors]
end
end
defp validate_did_format(errors, _, path), do: ["#{path}: must be a string" | errors]
defp validate_domain_format(errors, nil, _path), do: errors
defp validate_domain_format(errors, value, path) when is_binary(value) do
if Regex.match?(~r/^[a-z0-9][a-z0-9.\-]*[a-z0-9]$/, value) do
errors
else
["#{path}: must be a valid domain (got #{inspect(value)})" | errors]
end
end
defp validate_domain_format(errors, _, path), do: ["#{path}: must be a string" | errors]
defp validate_slug_format(errors, nil, _path), do: errors
defp validate_slug_format(errors, value, path) when is_binary(value) do
if Regex.match?(~r/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/, value) or String.match?(value, ~r/^\{\{.+\}\}$/) do
errors
else
["#{path}: must be a slug (got #{inspect(value)})" | errors]
end
end
defp validate_slug_format(errors, _, path), do: ["#{path}: must be a string" | errors]
# --- Primitive validators ---
defp require_string(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_binary(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a string"}}
end
end
defp require_integer_in(map, key, range, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_integer(v) -> if v in range, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be in #{inspect(range)}"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
end
end
defp require_positive_integer(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_integer(v) and v >= 1 -> :ok
v when is_integer(v) -> {:error, {:invalid_value, "#{section}.#{key} must be >= 1"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
end
end
defp require_string_list(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_list(v) -> if Enum.all?(v, &is_binary/1), do: :ok, else: {:error, {:invalid_type, "#{section}.#{key} must be a list of strings"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a list"}}
end
end
defp require_map(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_map(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a map"}}
end
end
defp require_inclusion(map, key, allowed, section) do
val = Map.get(map, key)
if is_nil(val) or val in allowed, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be one of #{inspect(allowed)}, got #{inspect(val)}"}}
end
defp optional_inclusion(_map, _key, _allowed, _section), do: :ok
defp optional_boolean(map, key, section) do
case Map.get(map, key) do
nil -> :ok
v when is_boolean(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a boolean"}}
end
end
defp validate_roles(nil, _path), do: :ok
defp validate_roles(roles, path) when is_list(roles) do
invalid = Enum.reject(roles, &(&1 in @valid_roles))
if invalid == [], do: :ok, else: {:error, {:invalid_value, "#{path} contains invalid roles: #{inspect(invalid)}"}}
end
defp validate_roles(_, path), do: {:error, {:invalid_type, "#{path} must be a list"}}
end

View file

@ -0,0 +1,100 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolver do
@moduledoc false
@placeholder_regex ~r/\{\{([a-z_]+)\}\}/
@known_variables ~w(trust_domain guild_slug guild_name registrant_did)
def resolve(template, params) when is_map(template) and is_map(params) do
with :ok <- check_params(params),
{:ok, resolved} <- substitute(template, params),
:ok <- check_no_remaining(resolved) do
{:ok, resolved}
end
end
def known_variables, do: @known_variables
defp check_params(params) do
errors =
Enum.reduce(@known_variables, [], fn var, acc ->
case Map.get(params, var) do
nil -> [{:missing_param, var} | acc]
"" -> [{:empty_param, var} | acc]
v when is_binary(v) -> acc
v -> [{:invalid_param, var, "expected string, got #{inspect(v)}"} | acc]
end
end)
placeholders_in_use = collect_placeholders(params |> Map.values() |> Enum.join(" "))
errors =
Enum.reduce(Map.keys(params) -- @known_variables, errors, fn key, acc ->
if key in placeholders_in_use, do: acc, else: acc
end)
case errors do
[] -> :ok
errs -> {:error, {:param_errors, Enum.reverse(errs)}}
end
end
defp substitute(value, params) when is_binary(value) do
result =
Regex.replace(@placeholder_regex, value, fn _full, var_name ->
case Map.get(params, var_name) do
nil -> "{{#{var_name}}}"
v -> to_string(v)
end
end)
{:ok, result}
end
defp substitute(value, params) when is_map(value) do
Enum.reduce_while(value, {:ok, %{}}, fn {k, v}, {:ok, acc} ->
case substitute(v, params) do
{:ok, resolved} -> {:cont, {:ok, Map.put(acc, k, resolved)}}
error -> {:halt, error}
end
end)
end
defp substitute(value, params) when is_list(value) do
Enum.reduce_while(value, {:ok, []}, fn v, {:ok, acc} ->
case substitute(v, params) do
{:ok, resolved} -> {:cont, {:ok, acc ++ [resolved]}}
error -> {:halt, error}
end
end)
end
defp substitute(value, _params), do: {:ok, value}
defp check_no_remaining(template) do
remaining = collect_all_placeholders(template)
case remaining do
[] -> :ok
vars -> {:error, {:unresolved_variables, Enum.uniq(vars)}}
end
end
defp collect_all_placeholders(value) when is_binary(value) do
collect_placeholders(value)
end
defp collect_all_placeholders(value) when is_map(value) do
Enum.flat_map(value, fn {_k, v} -> collect_all_placeholders(v) end)
end
defp collect_all_placeholders(value) when is_list(value) do
Enum.flat_map(value, &collect_all_placeholders/1)
end
defp collect_all_placeholders(_), do: []
defp collect_placeholders(string) when is_binary(string) do
Regex.scan(@placeholder_regex, string) |> Enum.map(fn [_, name] -> name end)
end
end

View file

@ -0,0 +1,118 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoder do
@moduledoc false
@section_paths %{
"trust_domain" => "trust-domain.yaml",
"identity_authority" => "identity-authority.yaml",
"members" => "members.yaml",
"infrastructure" => "infrastructure.yaml",
"ceremonies" => "ceremonies.yaml",
"federation_peers" => "federation-peers.yaml",
"attestation" => "attestation.yaml"
}
@section_declarations [
%{"name" => "trust-domain", "required" => true},
%{"name" => "identity-authority", "required" => true},
%{"name" => "members", "required" => true},
%{"name" => "infrastructure", "required" => true},
%{"name" => "ceremonies", "required" => true},
%{"name" => "federation-peers", "required" => false},
%{"name" => "attestation", "required" => false}
]
def encode(resolved_template, name, version, opts \\ []) do
hub_did = Keyword.get(opts, :hub_did, "did:web:guildhouse.dev")
registrant_spiffe = Keyword.get(opts, :registrant_spiffe)
trust_domain =
get_in(resolved_template, ["trust_domain", "spiffe_trust_domain"]) || "guildhouse.dev"
registrant_spiffe =
registrant_spiffe ||
"spiffe://#{trust_domain}/founder/#{get_in(resolved_template, ["identity_authority", "client_prefix"]) || "unknown"}"
manifest = build_manifest(name, version, hub_did, registrant_spiffe, resolved_template)
manifest_yaml = Jason.encode!(manifest)
files = build_section_files(resolved_template)
{:ok, manifest_yaml, files}
end
def section_paths, do: @section_paths
defp build_manifest(name, version, hub_did, registrant_spiffe, template) do
realm =
get_in(template, ["identity_authority", "client_prefix"]) ||
get_in(template, ["identity_authority", "realm"]) || name
%{
"apiVersion" => "guildhouse.dev/v1",
"kind" => "FfcSchematic",
"metadata" => %{
"name" => name,
"version" => version,
"description" =>
get_in(template, ["meta", "description"]) || "#{name} founding schematic",
"parentHash" => ""
},
"spec" => %{
"consortium" => %{
"did" => hub_did,
"realm" => realm
},
"stakeholders" => [
%{
"role" => "founding_stakeholder",
"identity" => registrant_spiffe,
"required" => true
}
],
"approval" => %{
"strategy" => "unanimous"
},
"sections" => @section_declarations,
"schematicHash" => "",
"ceremonyId" => "",
"accordHash" => "",
"signatures" => []
}
}
end
defp build_section_files(template) do
@section_paths
|> Enum.map(fn {section_key, file_path} ->
content = Map.get(template, section_key, %{})
content = strip_guildhall_metadata(section_key, content)
{file_path, Jason.encode!(content)}
end)
end
defp strip_guildhall_metadata("ceremonies", ceremonies) when is_map(ceremonies) do
Map.new(ceremonies, fn {key, rule} ->
if is_map(rule) do
{key, Map.delete(rule, "founding_override")}
else
{key, rule}
end
end)
end
defp strip_guildhall_metadata(_section, content), do: content
def extract_founding_overrides(template) do
ceremonies = Map.get(template, "ceremonies", %{})
Enum.reduce(ceremonies, %{}, fn {key, rule}, acc ->
case rule do
%{"founding_override" => override} when is_integer(override) ->
Map.put(acc, "ceremonies.#{key}.founding_override", override)
_ ->
acc
end
end)
end
end

View file

@ -4,10 +4,6 @@ description = "Independent Software Vendor founding schematic"
source_schematic = "guildhouse-isv-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} ISV Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 1
@ -15,10 +11,18 @@ attestation_tier = 1
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = false
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 1
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
@ -29,8 +33,11 @@ type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[members]
initial_roles = ["master"]
[federation_peers]
mode = "hub_spoke"
hub_trust_domain = "guildhouse.dev"
[infrastructure]
compute_attestation_tier = 1
[attestation]
tier = 1
require_tpm = false
require_secure_boot = false

View file

@ -4,10 +4,6 @@ description = "Managed Service Provider founding schematic"
source_schematic = "guildhouse-msp-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} MSP Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 2
@ -15,10 +11,18 @@ attestation_tier = 2
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 2
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
@ -29,8 +33,11 @@ type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[members]
initial_roles = ["master"]
[federation_peers]
mode = "hub_spoke"
hub_trust_domain = "guildhouse.dev"
[infrastructure]
compute_attestation_tier = 2
[attestation]
tier = 2
require_tpm = false
require_secure_boot = false

View file

@ -4,10 +4,6 @@ description = "Network Service Provider founding schematic"
source_schematic = "guildhouse-nsp-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} NSP Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 3
@ -15,11 +11,21 @@ attestation_tier = 3
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
hardware_credential_required = true
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 3
wireguard_tunnel = true
vpp_dataplane = true
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
@ -29,11 +35,13 @@ quorum = 1
type = "multi_party"
eligible_roles = ["master"]
quorum = 2
founding_override = 1
[members]
initial_roles = ["master"]
[federation_peers]
mode = "mesh"
hub_trust_domain = "guildhouse.dev"
[infrastructure]
compute_attestation_tier = 3
wireguard_tunnel = true
vpp_dataplane = true
[attestation]
tier = 3
require_tpm = true
require_secure_boot = true

View file

@ -0,0 +1,114 @@
defmodule Guildhall.Orchestrator.RealizationStatusTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.RealizationStatus
describe "classify_section/1" do
test "classifies all known statuses" do
assert {:succeeded, _} = RealizationStatus.classify_section("succeeded")
assert {:in_progress, _} = RealizationStatus.classify_section("in_progress")
assert {:failed, _} = RealizationStatus.classify_section("failed")
assert {:pending, _} = RealizationStatus.classify_section("pending")
assert {:skipped, _} = RealizationStatus.classify_section("skipped:no_change")
assert {:skipped, _} = RealizationStatus.classify_section("skipped:not_implemented")
assert {:skipped, label} = RealizationStatus.classify_section("skipped:upstream_unavailable:keycloak")
assert label =~ "keycloak"
end
test "unknown status returns pending with warning" do
assert {:pending, _} = RealizationStatus.classify_section("totally_unknown")
end
end
describe "derive_overall/1" do
test "all succeeded → realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "succeeded"}]}
assert :realized = RealizationStatus.derive_overall(snapshot)
end
test "all succeeded + skipped → realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "skipped:no_change"}]}
assert :realized = RealizationStatus.derive_overall(snapshot)
end
test "mix of failed and succeeded → partially_realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "failed"}]}
assert :partially_realized = RealizationStatus.derive_overall(snapshot)
end
test "all failed → failed" do
snapshot = %{sections: [%{status: "failed"}, %{status: "failed"}]}
assert :failed = RealizationStatus.derive_overall(snapshot)
end
test "in_progress → in_progress" do
snapshot = %{sections: [%{status: "in_progress"}, %{status: "pending"}]}
assert :in_progress = RealizationStatus.derive_overall(snapshot)
end
test "empty sections → pending" do
assert :pending = RealizationStatus.derive_overall(%{sections: []})
end
test "no sections key → pending" do
assert :pending = RealizationStatus.derive_overall(%{})
end
end
describe "terminal_status?/1" do
test "realized is terminal" do
assert RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED)
end
test "string form is terminal" do
assert RealizationStatus.terminal_status?("FFC_SCHEMATIC_STATUS_REALIZED")
end
test "published is not terminal" do
refute RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_PUBLISHED)
end
end
describe "display helpers" do
test "overall_css_class for each status" do
assert RealizationStatus.overall_css_class(:realized) =~ "emerald"
assert RealizationStatus.overall_css_class(:failed) =~ "red"
assert RealizationStatus.overall_css_class(:in_progress) =~ "amber"
assert RealizationStatus.overall_css_class(:pending) =~ "zinc"
end
test "display_status for each overall" do
assert "Realized" = RealizationStatus.display_status(:realized)
assert "Failed" = RealizationStatus.display_status(:failed)
assert "In progress" = RealizationStatus.display_status(:in_progress)
end
test "section_icon_class returns correct CSS" do
assert RealizationStatus.section_icon_class("succeeded") =~ "emerald"
assert RealizationStatus.section_icon_class("failed") =~ "red"
assert RealizationStatus.section_icon_class("skipped:no_change") =~ "sky"
end
test "section_symbol returns correct symbols" do
assert "" = RealizationStatus.section_symbol("succeeded")
assert "" = RealizationStatus.section_symbol("failed")
assert "" = RealizationStatus.section_symbol("skipped:not_implemented")
assert "" = RealizationStatus.section_symbol("pending")
end
test "section_display_status for skipped variants" do
assert RealizationStatus.section_display_status("skipped:no_change") =~ "no change"
assert RealizationStatus.section_display_status("skipped:not_implemented") =~ "stub"
end
end
describe "all_known_statuses/0" do
test "returns comprehensive list" do
statuses = RealizationStatus.all_known_statuses()
assert length(statuses) >= 7
assert "succeeded" in statuses
assert "failed" in statuses
assert "skipped:no_change" in statuses
end
end
end

View file

@ -0,0 +1,191 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.SchemaTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.Schema
defp valid_msp_template do
%{
"meta" => %{
"template_name" => "msp-founding",
"description" => "MSP founding schematic",
"source_schematic" => "guildhouse-msp-base",
"source_version" => "1.0.0"
},
"trust_domain" => %{
"spiffe_trust_domain" => "{{trust_domain}}",
"attestation_tier" => 2
},
"identity_authority" => %{
"provider" => "keycloak",
"url" => "https://auth.guildhouse.dev",
"realm" => "guildhouse",
"client_prefix" => "{{guild_slug}}",
"trust_level" => "federated",
"mfa_required" => true
},
"members" => %{
"founding_master_did" => "{{registrant_did}}",
"initial_roles" => ["master"]
},
"infrastructure" => %{
"compute_attestation_tier" => 2
},
"ceremonies" => %{
"code_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master", "journeyman"],
"quorum" => 1
},
"governance_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master"],
"quorum" => 1
}
},
"federation_peers" => %{
"mode" => "hub_spoke",
"hub_trust_domain" => "guildhouse.dev"
},
"attestation" => %{
"tier" => 2,
"require_tpm" => false,
"require_secure_boot" => false
}
}
end
describe "validate/1" do
test "valid MSP template passes" do
assert :ok = Schema.validate(valid_msp_template())
end
test "rejects unknown sections" do
template = Map.put(valid_msp_template(), "bogus", %{"foo" => "bar"})
assert {:error, {:unknown_sections, ["bogus"]}} = Schema.validate(template)
end
test "rejects nil template" do
assert {:error, {:missing_section, _}} = Schema.validate(nil)
end
test "rejects missing required section" do
template = Map.delete(valid_msp_template(), "trust_domain")
assert {:error, {:missing_section, "trust_domain"}} = Schema.validate(template)
end
test "rejects missing required field" do
template = put_in(valid_msp_template(), ["trust_domain", "spiffe_trust_domain"], nil)
template = Map.update!(template, "trust_domain", &Map.delete(&1, "spiffe_trust_domain"))
assert {:error, {:missing_field, "trust_domain.spiffe_trust_domain"}} = Schema.validate(template)
end
test "rejects invalid type for attestation_tier" do
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], "two")
assert {:error, {:invalid_type, _}} = Schema.validate(template)
end
test "rejects attestation_tier out of range" do
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], 5)
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
test "rejects invalid ceremony type" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "type"], "whatever")
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
test "rejects invalid role in eligible_roles" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "eligible_roles"], ["admin"])
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
end
describe "cross-section validation" do
test "detects tier mismatch" do
template = put_in(valid_msp_template(), ["attestation", "tier"], 3)
assert {:error, {:tier_mismatch, _}} = Schema.validate(template)
end
test "detects federation mode mismatch for MSP" do
template = put_in(valid_msp_template(), ["federation_peers", "mode"], "mesh")
# mesh also requires wireguard, but federation mode check runs first
assert {:error, {:federation_mode_mismatch, _}} = Schema.validate(template)
end
test "mesh requires wireguard" do
template =
valid_msp_template()
|> put_in(["meta", "template_name"], "nsp-founding")
|> put_in(["federation_peers", "mode"], "mesh")
assert {:error, {:mesh_requires_wireguard, _}} = Schema.validate(template)
end
end
describe "founding_override validation" do
test "rejects founding_override on single_approval" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "founding_override"], 1)
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
assert msg =~ "only valid on multi_party"
end
test "rejects founding_override >= quorum" do
template =
valid_msp_template()
|> put_in(["ceremonies", "governance_change"], %{
"type" => "multi_party",
"eligible_roles" => ["master"],
"quorum" => 2,
"founding_override" => 2
})
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
assert msg =~ "less than quorum"
end
test "accepts valid founding_override" do
template =
valid_msp_template()
|> put_in(["ceremonies", "governance_change"], %{
"type" => "multi_party",
"eligible_roles" => ["master"],
"quorum" => 2,
"founding_override" => 1
})
assert :ok = Schema.validate(template)
end
end
describe "validate_resolved/1" do
test "accepts valid resolved values" do
template =
valid_msp_template()
|> put_in(["trust_domain", "spiffe_trust_domain"], "test-guild.guildhouse.dev")
|> put_in(["members", "founding_master_did"], "did:web:guildhouse.dev:user:tking")
|> put_in(["identity_authority", "client_prefix"], "test-guild")
assert :ok = Schema.validate_resolved(template)
end
test "rejects invalid DID format" do
template =
valid_msp_template()
|> put_in(["members", "founding_master_did"], "not-a-did")
|> put_in(["trust_domain", "spiffe_trust_domain"], "test.guildhouse.dev")
|> put_in(["identity_authority", "client_prefix"], "test")
assert {:error, {:invalid_resolved_values, errors}} = Schema.validate_resolved(template)
assert Enum.any?(errors, &String.contains?(&1, "DID"))
end
end
describe "TOML template loading" do
test "all three templates validate" do
for type <- ["msp", "isv", "nsp"] do
{:ok, template} = Guildhall.Orchestrator.SchematicTemplate.load_template(type)
assert :ok = Schema.validate(template), "#{type} template failed validation"
end
end
end
end

View file

@ -0,0 +1,88 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolverTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.VariableResolver
@valid_params %{
"trust_domain" => "test-guild.guildhouse.dev",
"guild_slug" => "test-guild",
"guild_name" => "Test Guild",
"registrant_did" => "did:web:guildhouse.dev:user:tking"
}
defp template_with_placeholders do
%{
"trust_domain" => %{
"spiffe_trust_domain" => "{{trust_domain}}"
},
"members" => %{
"founding_master_did" => "{{registrant_did}}"
},
"identity_authority" => %{
"client_prefix" => "{{guild_slug}}"
}
}
end
describe "resolve/2" do
test "resolves all placeholders" do
{:ok, resolved} = VariableResolver.resolve(template_with_placeholders(), @valid_params)
assert resolved["trust_domain"]["spiffe_trust_domain"] == "test-guild.guildhouse.dev"
assert resolved["members"]["founding_master_did"] == "did:web:guildhouse.dev:user:tking"
assert resolved["identity_authority"]["client_prefix"] == "test-guild"
end
test "returns error for missing required param" do
params = Map.delete(@valid_params, "trust_domain")
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
assert Enum.any?(errors, fn {type, key} -> type == :missing_param and key == "trust_domain" end)
end
test "returns error for empty param" do
params = Map.put(@valid_params, "guild_slug", "")
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
assert Enum.any?(errors, fn {type, key} -> type == :empty_param and key == "guild_slug" end)
end
test "returns error for non-string param" do
params = Map.put(@valid_params, "guild_slug", 42)
assert {:error, {:param_errors, _}} = VariableResolver.resolve(template_with_placeholders(), params)
end
test "resolves nested lists" do
template = %{
"roles" => ["{{guild_slug}}-admin", "{{guild_slug}}-user"],
"trust_domain" => %{"spiffe_trust_domain" => "x"},
"members" => %{"founding_master_did" => "x"},
"identity_authority" => %{"client_prefix" => "x"}
}
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
assert resolved["roles"] == ["test-guild-admin", "test-guild-user"]
end
test "leaves non-string values unchanged" do
template = %{
"tier" => 2,
"enabled" => true,
"trust_domain" => %{"spiffe_trust_domain" => "x"},
"members" => %{"founding_master_did" => "x"},
"identity_authority" => %{"client_prefix" => "x"}
}
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
assert resolved["tier"] == 2
assert resolved["enabled"] == true
end
end
describe "known_variables/0" do
test "returns the resolution manifest" do
vars = VariableResolver.known_variables()
assert "trust_domain" in vars
assert "guild_slug" in vars
assert "guild_name" in vars
assert "registrant_did" in vars
end
end
end

View file

@ -0,0 +1,139 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoderTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.WireEncoder
defp resolved_template do
%{
"meta" => %{
"template_name" => "msp-founding",
"description" => "MSP schematic",
"source_schematic" => "guildhouse-msp-base",
"source_version" => "1.0.0"
},
"trust_domain" => %{
"spiffe_trust_domain" => "test.guildhouse.dev",
"attestation_tier" => 2
},
"identity_authority" => %{
"provider" => "keycloak",
"url" => "https://auth.guildhouse.dev",
"realm" => "guildhouse",
"client_prefix" => "test-guild",
"trust_level" => "federated",
"mfa_required" => true
},
"members" => %{
"founding_master_did" => "did:web:guildhouse.dev:user:tking",
"initial_roles" => ["master"]
},
"infrastructure" => %{
"compute_attestation_tier" => 2
},
"ceremonies" => %{
"code_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master", "journeyman"],
"quorum" => 1
},
"governance_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master"],
"quorum" => 1
}
},
"federation_peers" => %{
"mode" => "hub_spoke",
"hub_trust_domain" => "guildhouse.dev"
},
"attestation" => %{
"tier" => 2,
"require_tpm" => false,
"require_secure_boot" => false
}
}
end
describe "encode/4" do
test "produces manifest YAML and 7 section files" do
{:ok, manifest_yaml, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
assert is_binary(manifest_yaml)
assert length(files) == 7
file_paths = Enum.map(files, fn {path, _} -> path end)
assert "trust-domain.yaml" in file_paths
assert "identity-authority.yaml" in file_paths
assert "members.yaml" in file_paths
assert "infrastructure.yaml" in file_paths
assert "ceremonies.yaml" in file_paths
assert "federation-peers.yaml" in file_paths
assert "attestation.yaml" in file_paths
end
test "manifest has correct structure" do
{:ok, manifest_yaml, _files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
manifest = Jason.decode!(manifest_yaml)
assert manifest["apiVersion"] == "guildhouse.dev/v1"
assert manifest["kind"] == "FfcSchematic"
assert manifest["metadata"]["name"] == "test-msp"
assert manifest["metadata"]["version"] == "1.0.0"
assert manifest["spec"]["consortium"]["did"] == "did:web:guildhouse.dev"
assert is_list(manifest["spec"]["stakeholders"])
assert manifest["spec"]["approval"]["strategy"] == "unanimous"
assert is_list(manifest["spec"]["sections"])
end
test "manifest uses custom hub_did" do
{:ok, manifest_yaml, _} =
WireEncoder.encode(resolved_template(), "test", "1.0.0", hub_did: "did:web:custom.dev")
manifest = Jason.decode!(manifest_yaml)
assert manifest["spec"]["consortium"]["did"] == "did:web:custom.dev"
end
test "section file content is valid JSON" do
{:ok, _manifest, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
for {path, content} <- files do
assert {:ok, _} = Jason.decode(content), "#{path} is not valid JSON"
end
end
end
describe "founding_override stripping" do
test "strips founding_override from ceremonies wire output" do
template =
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
{:ok, _manifest, files} = WireEncoder.encode(template, "test", "1.0.0")
{_, ceremonies_json} = Enum.find(files, fn {p, _} -> p == "ceremonies.yaml" end)
ceremonies = Jason.decode!(ceremonies_json)
refute Map.has_key?(ceremonies["governance_change"], "founding_override")
end
end
describe "extract_founding_overrides/1" do
test "extracts override values" do
template =
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
overrides = WireEncoder.extract_founding_overrides(template)
assert overrides["ceremonies.governance_change.founding_override"] == 1
end
test "returns empty map when no overrides" do
assert %{} = WireEncoder.extract_founding_overrides(resolved_template())
end
end
describe "section_paths/0" do
test "returns all 7 section path mappings" do
paths = WireEncoder.section_paths()
assert map_size(paths) == 7
end
end
end

View file

@ -2,6 +2,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
alias Guildhall.Orchestrator.RealizationStatus
@impl true
def mount(%{"slug" => slug}, _session, socket) do
@ -41,25 +42,18 @@ defmodule GuildhallWeb.GuildLive.Realization do
end
end
defp status_icon("succeeded"), do: "text-emerald-600"
defp status_icon("in_progress"), do: "text-amber-600"
defp status_icon("failed"), do: "text-red-600"
defp status_icon("pending"), do: "text-zinc-400"
defp status_icon(_), do: "text-zinc-400"
defp status_symbol("succeeded"), do: ""
defp status_symbol("in_progress"), do: ""
defp status_symbol("failed"), do: ""
defp status_symbol(_), do: ""
defp status_icon(status), do: RealizationStatus.section_icon_class(status)
defp status_symbol(status), do: RealizationStatus.section_symbol(status)
defp display_status(status), do: RealizationStatus.section_display_status(status)
defp overall_class(snapshot) do
overall = Map.get(snapshot, "overall_status", Map.get(snapshot, :overall_status, "pending"))
cond do
String.contains?(to_string(overall), "REALIZED") -> "bg-emerald-100 text-emerald-700"
String.contains?(to_string(overall), "PUBLISHED") -> "bg-amber-100 text-amber-700"
true -> "bg-zinc-100 text-zinc-700"
overall = RealizationStatus.derive_overall(snapshot)
RealizationStatus.overall_css_class(overall)
end
defp overall_label(snapshot) do
overall = RealizationStatus.derive_overall(snapshot)
RealizationStatus.display_status(overall)
end
@impl true
@ -72,7 +66,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">&larr; {@guild.name}</.link>
<h1 class="text-2xl font-semibold mt-2">Realization Dashboard</h1>
<div :if={@snapshot != %{}} class={"inline-block rounded-full px-3 py-1 text-xs font-medium mt-2 #{overall_class(@snapshot)}"}>
{Map.get(@snapshot, "overall_status", Map.get(@snapshot, :overall_status, "pending"))}
{overall_label(@snapshot)}
</div>
</header>
@ -93,7 +87,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
<div class="text-sm font-medium">{String.replace(name, "_", " ") |> String.capitalize()}</div>
<div class="text-xs text-zinc-500">{s.message}</div>
</div>
<span class={"text-xs #{status_icon(s.status)}"}>{s.status}</span>
<span class={"text-xs #{status_icon(s.status)}"}>{display_status(s.status)}</span>
</div>
</section>
</div>

View file

@ -2,7 +2,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
alias Guildhall.Orchestrator.{SchematicClient, SchematicTemplate, RealizationPoller}
alias Guildhall.Orchestrator.{FfcPipeline, SchematicTemplate}
@impl true
def mount(%{"slug" => slug}, _session, socket) do
@ -39,56 +39,22 @@ defmodule GuildhallWeb.GuildLive.Schematic do
@impl true
def handle_event("deploy_schematic", _params, socket) do
guild = socket.assigns.guild
template = socket.assigns.template
socket = assign(socket, :deploying, true)
params = %{
"guild_slug" => guild.slug,
"guild_name" => guild.name,
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
"registrant_did" => guild.registrant_did
}
rendered = SchematicTemplate.render_template(template, params)
operations = SchematicTemplate.to_fork_operations(rendered)
{source_name, source_version} = SchematicTemplate.source_schematic(template)
schematic_name = "#{guild.slug}-#{guild.guild_type}-founding"
schematic_version = "1.0.0"
with {:ok, fork_resp} <-
SchematicClient.fork_schematic(source_name, source_version, schematic_name, schematic_version, operations),
{:ok, binding_resp} <-
SchematicClient.create_deployment_binding(schematic_name, schematic_version, "founding", "production"),
{:ok, realize_resp} <-
SchematicClient.realize_ffc_schematic(schematic_name, schematic_version) do
{:ok, gs} =
GuildSchematics.create(%{
guild_id: guild.id,
template_name: "#{guild.guild_type}-founding",
schematic_name: schematic_name,
schematic_version: schematic_version,
tree_hash: Map.get(fork_resp, :tree_hash, ""),
binding_id: Map.get(binding_resp, :binding_id, ""),
realization_id: Map.get(realize_resp, :realization_id, ""),
status: "realizing"
})
if gs.realization_id && gs.realization_id != "" do
RealizationPoller.watch(gs.realization_id, guild.slug)
end
case FfcPipeline.deploy(guild) do
{:ok, _state} ->
{:ok, _} = Guilds.update_guild(guild, %{status: "active"})
{:noreply,
socket
|> put_flash(:info, "Schematic deployed. Realization in progress.")
|> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")}
else
{:error, reason} ->
{:error, {step, reason}} ->
{:noreply,
socket
|> assign(:deploying, false)
|> assign(:deploy_error, inspect(reason))}
|> assign(:deploy_error, "Step #{step} failed: #{inspect(reason)}")}
end
end
@ -124,8 +90,8 @@ defmodule GuildhallWeb.GuildLive.Schematic do
<div class="text-sm text-zinc-500 space-y-1">
<p>This will:</p>
<ol class="list-decimal ml-5 space-y-1">
<li>Fork the founding schematic template for {@guild.name}</li>
<li>Create a deployment binding (production)</li>
<li>Validate and resolve the founding schematic template</li>
<li>Create, validate, and publish the FFC schematic</li>
<li>Trigger realization across all reconciler sections</li>
<li>Transition guild to <span class="font-semibold">active</span> status</li>
</ol>