feat(orchestrator): governance correctness — override revocation + bootstrap ceremony

Wire founding override enforcement (TTL guard, periodic sweep, second-
master auto-revoke, manual revocation) and replace the approve stub with
a real Ed25519 signing flow through two bootstrap modes (self-sovereign
and partner-hosted with Guildhouse as default partner).

Pipeline now pauses at awaiting_approval, returns schematic_hash for the
signer, and resumes via POST /api/approvals webhook. HostingAgreement
table + HostingCeremony module support partner-hosted onboarding with
auto-ratification for Guildhouse-as-partner.

70 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 12:54:55 -04:00
parent 50c488b92b
commit 38cf2b7c6b
27 changed files with 1335 additions and 71 deletions

View file

@ -44,8 +44,9 @@ Pre-existing: `governed_artifacts`, `deployment_states`, `verification_results`
New guild tables: New guild tables:
- **guilds**`guild_id` (10-bit, 0x0100x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did` - **guilds**`guild_id` (10-bit, 0x0100x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did`
- **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/forked/binding_created/realizing/realized/failed), `realization_snapshot` (map) - **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/draft/validated/hosting_agreed/awaiting_approval/approved/published/realizing/realized/partially_realized/failed), `realization_snapshot` (map), `bootstrap_mode` (self_sovereign/partner_hosted), `founding_override_expires_at`, `founding_override_revoked_at`, `founding_override_revocation_reason`, `approval_requested_at`, `approved_at`, `approval_metadata` (map)
- **guild_memberships** — FK to guilds, `user_did`, `role` (apprentice/journeyman/master), `status` (pending/approved/denied/active/suspended/removed), `membership_ceremony_id`, unique on (guild_id, user_did) - **guild_memberships** — FK to guilds, `user_did`, `role` (apprentice/journeyman/master), `status` (pending/approved/denied/active/suspended/removed), `membership_ceremony_id`, unique on (guild_id, user_did)
- **hosting_agreements** — FK to guild_schematics + guilds (partner), `hosting_did`, `terms` (map), `partner_signature` (binary, Ed25519), `status` (proposed/ratified/active/handoff_pending/completed/expired), `auto_ratified`, `expires_at`
## Key flows ## Key flows
@ -53,11 +54,22 @@ New guild tables:
1. User fills form at `/guilds/register` → creates guild row (status: pending_approval) + ceremony via `CeremonyClient.create_guild_registration_ceremony` 1. User fills form at `/guilds/register` → creates guild row (status: pending_approval) + ceremony via `CeremonyClient.create_guild_registration_ceremony`
2. Hub operator (tking) sees pending guild at `/guilds/:slug`, clicks Approve → `CeremonyClient.approve_ceremony` + `Guilds.approve_guild/1` (Ecto.Multi: updates guild to "approved" + creates registrant as guild master) 2. Hub operator (tking) sees pending guild at `/guilds/:slug`, clicks Approve → `CeremonyClient.approve_ceremony` + `Guilds.approve_guild/1` (Ecto.Multi: updates guild to "approved" + creates registrant as guild master)
### Schematic deployment ### Schematic deployment (FfcPipeline)
1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type from `priv/schematic_templates/` 1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type
2. Click Deploy → `SchematicClient.fork_schematic``create_deployment_binding``realize_ffc_schematic` 2. Click Deploy → `FfcPipeline.deploy/2` runs 11 steps: load → validate → resolve → validate_resolved → encode → create_on_server → validate_on_server → create_db_record → fetch_schematic_hash → check_founding_override → check_hosting_agreement
3. Creates `guild_schematics` row, starts `RealizationPoller.watch/2` 3. Pipeline pauses at `awaiting_approval` — returns `{:paused, state}` with `schematic_hash` for signing
4. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates 4. External signer POSTs Ed25519 signature to `POST /api/approvals` (bearer token auth)
5. `ApprovalCoordinator` validates signer + calls `FfcPipeline.resume_after_approval/2`: approve_rpc → publish → realize → update_db → start_poller
6. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates
### Bootstrap modes
- **self_sovereign** — Guild has own FFC. Founding master signs from their own GSH.
- **partner_hosted** — Partner guild (default: Guildhouse `guildhouse-ops`) hosts FFC during bootstrap. Requires ratified `HostingAgreement`. Guildhouse auto-ratifies for NSP tiers.
### Founding override lifecycle
- NSP templates use `founding_override` to reduce multi-party quorum to 1 during bootstrap
- 90-day TTL enforced by `FoundingOverrideGuard` (inline check) + `FoundingOverrideMonitor` (hourly sweep)
- Revoked automatically when second master onboards, or manually by founding master on GuildLive.Show
### Member onboarding ### Member onboarding
1. User visits `/guilds/:slug/join` → creates membership (pending) + ceremony for guild master approval 1. User visits `/guilds/:slug/join` → creates membership (pending) + ceremony for guild master approval
@ -81,10 +93,18 @@ All guild routes are under authenticated `live_session :authenticated`:
Public: `/auth/login`, `/auth/callback`, `/auth/logout`, `/health` Public: `/auth/login`, `/auth/callback`, `/auth/logout`, `/health`
API (bearer token `GUILDHALL_APPROVAL_WEBHOOK_SECRET`):
```
POST /api/approvals — receive Ed25519 signature for pending schematic
POST /api/hosting-agreements/:id/ratify — partner ratifies hosting agreement
```
## Orchestrator supervision tree ## Orchestrator supervision tree
- `Guildhall.Orchestrator.CeremonyOrchestrator` — existing ceremony workflow coordinator - `Guildhall.Orchestrator.CeremonyOrchestrator` — existing ceremony workflow coordinator
- `Guildhall.Orchestrator.RealizationPoller` — GenServer, polls realization status every 5s for watched IDs, broadcasts on `"realization:#{guild_slug}"` PubSub topic - `Guildhall.Orchestrator.RealizationPoller` — GenServer, polls realization status every 5s for watched IDs, broadcasts on `"realization:#{guild_slug}"` PubSub topic
- `Guildhall.Orchestrator.FoundingOverrideMonitor` — GenServer, hourly sweep of expired overrides, broadcasts `{:founding_override_revoked, ...}` on `"override:#{slug}"`
- `Guildhall.Orchestrator.ApprovalCoordinator` — GenServer, tracks pending approvals by `"name:version"`, dispatches `resume_after_approval` when signature received
## Schematic templates ## Schematic templates

View file

@ -31,13 +31,23 @@ defmodule Guildhall.OpsDb.GuildMemberships do
end end
def approve_membership(%GuildMembership{} = membership, approver_did) do def approve_membership(%GuildMembership{} = membership, approver_did) do
membership result =
|> GuildMembership.changeset(%{ membership
status: "active", |> GuildMembership.changeset(%{
approved_by_did: approver_did, status: "active",
approved_at: DateTime.utc_now() approved_by_did: approver_did,
}) approved_at: DateTime.utc_now()
|> Repo.update() })
|> Repo.update()
case result do
{:ok, m} ->
maybe_revoke_founding_override(m.guild_id)
{:ok, m}
error ->
error
end
end end
def deny_membership(%GuildMembership{} = membership, approver_did) do def deny_membership(%GuildMembership{} = membership, approver_did) do
@ -51,8 +61,29 @@ defmodule Guildhall.OpsDb.GuildMemberships do
end end
def update_role(%GuildMembership{} = membership, new_role) do def update_role(%GuildMembership{} = membership, new_role) do
membership result =
|> GuildMembership.changeset(%{role: new_role}) membership
|> Repo.update() |> GuildMembership.changeset(%{role: new_role})
|> Repo.update()
case result do
{:ok, m} ->
if new_role == "master", do: maybe_revoke_founding_override(m.guild_id)
{:ok, m}
error ->
error
end
end
defp maybe_revoke_founding_override(guild_id) do
masters = masters_for_guild(guild_id)
if length(masters) >= 2 do
Guildhall.OpsDb.GuildSchematics.revoke_founding_override(
guild_id,
"second_master_onboarded"
)
end
end end
end end

View file

@ -17,10 +17,20 @@ defmodule Guildhall.OpsDb.GuildSchematic do
field :customization_params, :map, default: %{} field :customization_params, :map, default: %{}
field :realization_snapshot, :map, default: %{} field :realization_snapshot, :map, default: %{}
field :founding_override_expires_at, :utc_datetime_usec field :founding_override_expires_at, :utc_datetime_usec
field :founding_override_revoked_at, :utc_datetime_usec
field :founding_override_revocation_reason, :string
field :bootstrap_mode, :string, default: "self_sovereign"
field :approval_requested_at, :utc_datetime_usec
field :approved_at, :utc_datetime_usec
field :approval_metadata, :map, default: %{}
has_many :hosting_agreements, Guildhall.OpsDb.HostingAgreement
timestamps(type: :utc_datetime_usec) timestamps(type: :utc_datetime_usec)
end end
@statuses ~w(pending draft validated hosting_agreed awaiting_approval approved published realizing realized partially_realized failed)
def changeset(schematic, attrs) do def changeset(schematic, attrs) do
schematic schematic
|> cast(attrs, [ |> cast(attrs, [
@ -34,10 +44,17 @@ defmodule Guildhall.OpsDb.GuildSchematic do
:status, :status,
:customization_params, :customization_params,
:realization_snapshot, :realization_snapshot,
:founding_override_expires_at :founding_override_expires_at,
:founding_override_revoked_at,
:founding_override_revocation_reason,
:bootstrap_mode,
:approval_requested_at,
:approved_at,
:approval_metadata
]) ])
|> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version]) |> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version])
|> validate_inclusion(:status, ~w(pending draft validated approved published realizing realized partially_realized failed)) |> validate_inclusion(:status, @statuses)
|> validate_inclusion(:bootstrap_mode, ~w(self_sovereign partner_hosted))
|> foreign_key_constraint(:guild_id) |> foreign_key_constraint(:guild_id)
|> unique_constraint([:schematic_name, :schematic_version]) |> unique_constraint([:schematic_name, :schematic_version])
end end

View file

@ -27,4 +27,46 @@ defmodule Guildhall.OpsDb.GuildSchematics do
def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do
update_schematic(gs, %{realization_snapshot: snapshot}) update_schematic(gs, %{realization_snapshot: snapshot})
end end
def revoke_founding_override(guild_id, reason) do
case latest_with_active_override(guild_id) do
nil ->
{:ok, :no_override}
gs ->
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
update_schematic(gs, %{
founding_override_revoked_at: now,
founding_override_revocation_reason: reason
})
end
end
def schematics_with_expired_overrides do
now = DateTime.utc_now()
Repo.all(
from(gs in GuildSchematic,
where:
not is_nil(gs.founding_override_expires_at) and
is_nil(gs.founding_override_revoked_at) and
gs.founding_override_expires_at < ^now,
preload: [:guild]
)
)
end
defp latest_with_active_override(guild_id) do
Repo.one(
from(gs in GuildSchematic,
where:
gs.guild_id == ^guild_id and
not is_nil(gs.founding_override_expires_at) and
is_nil(gs.founding_override_revoked_at),
order_by: [desc: gs.inserted_at],
limit: 1
)
)
end
end end

View file

@ -0,0 +1,51 @@
defmodule Guildhall.OpsDb.HostingAgreement do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
@statuses ~w(proposed ratified active handoff_pending completed expired)
schema "hosting_agreements" do
belongs_to :guild_schematic, Guildhall.OpsDb.GuildSchematic
belongs_to :partner_guild, Guildhall.OpsDb.Guild
field :hosting_did, :string
field :terms, :map, default: %{}
field :partner_signature, :binary
field :partner_signed_at, :utc_datetime_usec
field :auto_ratified, :boolean, default: false
field :status, :string, default: "proposed"
field :expires_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
def changeset(agreement, attrs) do
agreement
|> cast(attrs, [
:guild_schematic_id,
:partner_guild_id,
:hosting_did,
:terms,
:partner_signature,
:partner_signed_at,
:auto_ratified,
:status,
:expires_at
])
|> validate_required([:guild_schematic_id, :partner_guild_id, :hosting_did, :expires_at])
|> validate_inclusion(:status, @statuses)
|> validate_signature_length()
|> foreign_key_constraint(:guild_schematic_id)
|> foreign_key_constraint(:partner_guild_id)
end
defp validate_signature_length(changeset) do
case get_change(changeset, :partner_signature) do
nil -> changeset
sig when byte_size(sig) == 64 -> changeset
_ -> add_error(changeset, :partner_signature, "must be exactly 64 bytes (Ed25519)")
end
end
end

View file

@ -0,0 +1,44 @@
defmodule Guildhall.OpsDb.HostingAgreements do
import Ecto.Query, only: [from: 2]
alias Guildhall.OpsDb.{Repo, HostingAgreement}
def get!(id), do: Repo.get!(HostingAgreement, id)
def get_for_schematic(guild_schematic_id) do
Repo.one(
from(ha in HostingAgreement,
where: ha.guild_schematic_id == ^guild_schematic_id,
order_by: [desc: ha.inserted_at],
limit: 1
)
)
end
def get_ratified_for_schematic(guild_schematic_id) do
Repo.one(
from(ha in HostingAgreement,
where: ha.guild_schematic_id == ^guild_schematic_id and ha.status in ["ratified", "active"],
order_by: [desc: ha.inserted_at],
limit: 1
)
)
end
def create(attrs) do
%HostingAgreement{}
|> HostingAgreement.changeset(attrs)
|> Repo.insert()
end
def update(%HostingAgreement{} = ha, attrs) do
ha
|> HostingAgreement.changeset(attrs)
|> Repo.update()
end
def ratify(%HostingAgreement{status: "proposed"} = ha, attrs) do
update(ha, Map.merge(attrs, %{status: "ratified", partner_signed_at: DateTime.utc_now()}))
end
def ratify(%HostingAgreement{}, _attrs), do: {:error, :not_proposed}
end

View file

@ -0,0 +1,30 @@
defmodule Guildhall.OpsDb.Repo.Migrations.AddOverrideRevocationAndBootstrap do
use Ecto.Migration
def change do
alter table(:guild_schematics) do
add :founding_override_revoked_at, :utc_datetime_usec
add :founding_override_revocation_reason, :string
add :bootstrap_mode, :string, default: "self_sovereign"
add :approval_requested_at, :utc_datetime_usec
add :approved_at, :utc_datetime_usec
add :approval_metadata, :map, default: %{}
end
create table(:hosting_agreements) do
add :guild_schematic_id, references(:guild_schematics, on_delete: :delete_all), null: false
add :partner_guild_id, references(:guilds, on_delete: :restrict), null: false
add :hosting_did, :string, null: false
add :terms, :map, default: %{}
add :partner_signature, :binary
add :partner_signed_at, :utc_datetime_usec
add :auto_ratified, :boolean, default: false
add :status, :string, default: "proposed"
add :expires_at, :utc_datetime_usec, null: false
timestamps(type: :utc_datetime_usec)
end
create index(:hosting_agreements, [:guild_schematic_id])
create index(:hosting_agreements, [:partner_guild_id])
end
end

View file

@ -6,6 +6,7 @@
alias Guildhall.OpsDb.{ alias Guildhall.OpsDb.{
Repo, Repo,
Guild,
AccordBinding, AccordBinding,
GovernedArtifact, GovernedArtifact,
CustodyTransition, CustodyTransition,
@ -15,6 +16,26 @@ alias Guildhall.OpsDb.{
now = DateTime.utc_now() now = DateTime.utc_now()
# ────────────────────────────────────────────────────────
# 0. Guildhouse operational guild (default hosting partner)
# ────────────────────────────────────────────────────────
_guildhouse =
Repo.insert!(
%Guild{}
|> Guild.changeset(%{
guild_id: 0x001,
name: "Guildhouse Operations",
slug: "guildhouse-ops",
guild_type: "nsp",
status: "active",
registrant_did: "did:web:guildhouse.dev:user:tking",
contact_did: "did:web:guildhouse.dev:user:tking",
trust_domain: "guildhouse.dev"
}),
on_conflict: :nothing,
conflict_target: :slug
)
# ──────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────
# 1. Accord binding (shared by all artifacts below) # 1. Accord binding (shared by all artifacts below)
# ──────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────

View file

@ -6,7 +6,9 @@ defmodule Guildhall.Orchestrator.Application do
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
Guildhall.Orchestrator.CeremonyOrchestrator, Guildhall.Orchestrator.CeremonyOrchestrator,
Guildhall.Orchestrator.RealizationPoller Guildhall.Orchestrator.RealizationPoller,
Guildhall.Orchestrator.FoundingOverrideMonitor,
Guildhall.Orchestrator.ApprovalCoordinator
] ]
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor] opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]

View file

@ -0,0 +1,97 @@
defmodule Guildhall.Orchestrator.ApprovalCoordinator do
@moduledoc false
use GenServer
require Logger
alias Guildhall.Orchestrator.{ApprovalPayload, FfcPipeline}
alias Guildhall.OpsDb.{GuildMemberships, HostingAgreements}
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def register_pending(guild_schematic) do
GenServer.call(__MODULE__, {:register, guild_schematic})
end
def receive_approval(%ApprovalPayload{} = payload) do
GenServer.call(__MODULE__, {:approve, payload}, 30_000)
end
def pending_count do
GenServer.call(__MODULE__, :pending_count)
end
@impl true
def init(_opts) do
{:ok, %{pending: %{}}}
end
@impl true
def handle_call({:register, gs}, _from, state) do
key = "#{gs.schematic_name}:#{gs.schematic_version}"
pending = Map.put(state.pending, key, gs)
Logger.info("Registered pending approval: #{key}")
{:reply, :ok, %{state | pending: pending}}
end
def handle_call({:approve, payload}, _from, state) do
key = "#{payload.schematic_name}:#{payload.schematic_version}"
case Map.get(state.pending, key) do
nil ->
{:reply, {:error, :not_pending}, state}
gs ->
case process_approval(gs, payload) do
{:ok, result_state} ->
pending = Map.delete(state.pending, key)
slug = gs.schematic_name
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"approval:#{slug}",
{:approval_complete,
%{schematic_name: gs.schematic_name, schematic_version: gs.schematic_version}}
)
{:reply, {:ok, result_state}, %{state | pending: pending}}
error ->
{:reply, error, state}
end
end
end
def handle_call(:pending_count, _from, state) do
{:reply, map_size(state.pending), state}
end
defp process_approval(gs, payload) do
with :ok <- verify_signer_authorized(gs, payload),
:ok <- verify_hosting_if_needed(gs),
:ok <- ApprovalPayload.validate(payload) do
FfcPipeline.resume_after_approval(gs, payload)
end
end
defp verify_signer_authorized(gs, payload) do
masters = GuildMemberships.masters_for_guild(gs.guild_id)
if Enum.any?(masters, fn m -> m.user_did == payload.signer_did end) do
:ok
else
{:error, :signer_not_authorized}
end
end
defp verify_hosting_if_needed(%{bootstrap_mode: "partner_hosted"} = gs) do
case HostingAgreements.get_ratified_for_schematic(gs.id) do
nil -> {:error, :hosting_agreement_not_ratified}
_ha -> :ok
end
end
defp verify_hosting_if_needed(_gs), do: :ok
end

View file

@ -0,0 +1,52 @@
defmodule Guildhall.Orchestrator.ApprovalPayload do
@moduledoc false
@enforce_keys [:schematic_name, :schematic_version, :signer_did, :signature, :accord_hash]
defstruct [
:schematic_name,
:schematic_version,
:signer_did,
:signature,
:accord_hash,
:key_ref,
:hosting_agreement_id,
role: :FFC_ROLE_FOUNDING_STAKEHOLDER
]
@type t :: %__MODULE__{
schematic_name: String.t(),
schematic_version: String.t(),
signer_did: String.t(),
role: atom(),
signature: binary(),
accord_hash: binary(),
key_ref: String.t() | nil,
hosting_agreement_id: integer() | nil
}
def validate(%__MODULE__{} = payload) do
errors =
[]
|> check_signature(payload.signature)
|> check_signer_did(payload.signer_did)
|> check_non_empty(:schematic_name, payload.schematic_name)
|> check_non_empty(:schematic_version, payload.schematic_version)
|> check_non_empty(:accord_hash, payload.accord_hash)
case errors do
[] -> :ok
errors -> {:error, {:validation_errors, Enum.reverse(errors)}}
end
end
defp check_signature(errors, sig) when is_binary(sig) and byte_size(sig) == 64, do: errors
defp check_signature(errors, _), do: [{:signature, "must be exactly 64 bytes"} | errors]
defp check_signer_did(errors, "did:" <> _), do: errors
defp check_signer_did(errors, _), do: [{:signer_did, "must start with did:"} | errors]
defp check_non_empty(errors, _field, value) when is_binary(value) and byte_size(value) > 0,
do: errors
defp check_non_empty(errors, field, _), do: [{field, "must be non-empty"} | errors]
end

View file

@ -5,8 +5,8 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
alias Guildhall.Orchestrator.SchematicTemplate alias Guildhall.Orchestrator.SchematicTemplate
alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder} alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder}
alias Guildhall.Orchestrator.RealizationPoller alias Guildhall.Orchestrator.{FoundingOverrideGuard, RealizationPoller}
alias Guildhall.OpsDb.GuildSchematics alias Guildhall.OpsDb.{GuildSchematics, HostingAgreements}
defstruct [ defstruct [
:guild, :guild,
@ -19,13 +19,17 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
:schematic_name, :schematic_name,
:schematic_version, :schematic_version,
:guild_schematic, :guild_schematic,
:realization_id :realization_id,
:schematic_hash,
:accord_hash,
:bootstrap_mode
] ]
def deploy(guild, opts \\ []) do def deploy(guild, opts \\ []) do
guild_type = guild.guild_type guild_type = guild.guild_type
version = Keyword.get(opts, :version, "1.0.0") version = Keyword.get(opts, :version, "1.0.0")
schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}") schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}")
bootstrap_mode = Keyword.get(opts, :bootstrap_mode, "self_sovereign")
params = %{ params = %{
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev", "trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
@ -38,7 +42,8 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
guild: guild, guild: guild,
guild_type: guild_type, guild_type: guild_type,
schematic_name: schematic_name, schematic_name: schematic_name,
schematic_version: version schematic_version: version,
bootstrap_mode: bootstrap_mode
} }
with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end), with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end),
@ -48,12 +53,31 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
{:ok, state} <- step(:encode_wire, state, fn -> encode_wire(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(:create_schematic, state, fn -> create_schematic(state) end),
{:ok, state} <- step(:validate_schematic, state, fn -> validate_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(:create_db_record, state, fn -> create_db_record(state) end),
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do {:ok, state} <- step(:fetch_schematic_hash, state, fn -> fetch_schematic_hash(state) end),
{:ok, state} {:ok, state} <- step(:check_founding_override, state, fn -> check_founding_override(state) end),
{:ok, state} <- step(:check_hosting_agreement, state, fn -> check_hosting_agreement(state) end) do
request_approval(state)
end
end
def resume_after_approval(guild_schematic, %{} = approval_payload) do
if guild_schematic.status != "awaiting_approval" do
{:error, {:resume, :not_awaiting_approval}}
else
state = %__MODULE__{
guild_schematic: guild_schematic,
schematic_name: guild_schematic.schematic_name,
schematic_version: guild_schematic.schematic_version
}
with {:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state, approval_payload) 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(:update_db_realized, state, fn -> update_db_realized(state) end),
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do
{:ok, state}
end
end end
end end
@ -124,25 +148,6 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
end end
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 defp create_db_record(%{guild: guild} = state) do
override_ttl_days = override_ttl_days =
Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90) Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90)
@ -159,10 +164,10 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
template_name: "#{state.guild_type}-founding", template_name: "#{state.guild_type}-founding",
schematic_name: state.schematic_name, schematic_name: state.schematic_name,
schematic_version: state.schematic_version, schematic_version: state.schematic_version,
realization_id: state.realization_id, status: "draft",
status: "realizing",
customization_params: overrides, customization_params: overrides,
founding_override_expires_at: expires_at founding_override_expires_at: expires_at,
bootstrap_mode: state.bootstrap_mode
} }
case GuildSchematics.create(attrs) do case GuildSchematics.create(attrs) do
@ -171,8 +176,107 @@ defmodule Guildhall.Orchestrator.FfcPipeline do
end end
end end
defp start_poller(%{realization_id: realization_id, guild: guild} = state) do defp fetch_schematic_hash(%{schematic_name: name, schematic_version: version} = state) do
RealizationPoller.watch(realization_id, guild.slug) case client().get_ffc_schematic(name, version) do
{:ok, response} ->
artifact = response.artifact
{:ok, %{state | schematic_hash: artifact.schematic_hash, accord_hash: artifact.accord_hash}}
error ->
error
end
end
defp check_founding_override(%{guild_schematic: gs} = state) do
case FoundingOverrideGuard.check(gs) do
:ok -> {:ok, state}
error -> error
end
end
defp check_hosting_agreement(%{bootstrap_mode: "self_sovereign"} = state), do: {:ok, state}
defp check_hosting_agreement(%{bootstrap_mode: "partner_hosted", guild_schematic: gs} = state) do
case HostingAgreements.get_ratified_for_schematic(gs.id) do
nil -> {:error, :hosting_agreement_required}
_ha -> {:ok, state}
end
end
defp request_approval(%{guild_schematic: gs} = state) do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
case GuildSchematics.update_schematic(gs, %{
status: "awaiting_approval",
approval_requested_at: now
}) do
{:ok, updated_gs} ->
{:paused, %{state | guild_schematic: updated_gs}}
error ->
{:error, {:request_approval, error}}
end
end
defp approve_schematic(%{schematic_name: name, schematic_version: version} = state, payload) do
attrs = %{
schematic_name: name,
schematic_version: version,
signer_did: payload.signer_did,
role: payload.role,
signature: payload.signature,
accord_hash: payload.accord_hash,
key_ref: payload.key_ref
}
case client().approve_ffc_schematic(attrs) do
{:ok, _response} ->
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
GuildSchematics.update_schematic(state.guild_schematic, %{
status: "approved",
approved_at: now,
approval_metadata: %{
"signer_did" => payload.signer_did,
"hosting_agreement_id" => payload.hosting_agreement_id
}
})
{:ok, state}
error ->
GuildSchematics.update_schematic(state.guild_schematic, %{status: "failed"})
error
end
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 update_db_realized(%{guild_schematic: gs, realization_id: rid} = state) do
case GuildSchematics.update_schematic(gs, %{
realization_id: rid,
status: "realizing"
}) do
{:ok, updated} -> {:ok, %{state | guild_schematic: updated}}
error -> error
end
end
defp start_poller(%{realization_id: realization_id, guild_schematic: gs} = state) do
slug = gs.schematic_name
RealizationPoller.watch(realization_id, slug)
{:ok, state} {:ok, state}
end end

View file

@ -0,0 +1,46 @@
defmodule Guildhall.Orchestrator.FoundingOverrideGuard do
@moduledoc false
def check(%{customization_params: params} = gs) when is_map(params) do
if has_overrides?(params) do
check_override_state(gs)
else
:ok
end
end
def check(%{}), do: :ok
defp check_override_state(%{founding_override_revoked_at: revoked_at} = gs)
when not is_nil(revoked_at) do
{:error, {:override_revoked, gs.founding_override_revocation_reason || "revoked"}}
end
defp check_override_state(%{founding_override_expires_at: nil}) do
{:error, :override_missing_expiry}
end
defp check_override_state(%{founding_override_expires_at: expires_at}) do
if DateTime.compare(DateTime.utc_now(), expires_at) == :gt do
{:error, :override_ttl_expired}
else
:ok
end
end
def effective_quorum(%{customization_params: params} = gs, ceremony_key) when is_map(params) do
override_key = "ceremonies.#{ceremony_key}.founding_override"
case {Map.get(params, override_key), check(gs)} do
{nil, _} -> :no_override
{value, :ok} -> {:override, value}
{_value, {:error, _}} -> :no_override
end
end
def effective_quorum(%{}, _ceremony_key), do: :no_override
defp has_overrides?(params) do
Enum.any?(params, fn {key, _} -> String.contains?(key, "founding_override") end)
end
end

View file

@ -0,0 +1,59 @@
defmodule Guildhall.Orchestrator.FoundingOverrideMonitor do
@moduledoc false
use GenServer
require Logger
alias Guildhall.OpsDb.GuildSchematics
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
interval = Keyword.get(opts, :interval_ms, default_interval())
Process.send_after(self(), :sweep, interval)
{:ok, %{interval: interval}}
end
@impl true
def handle_info(:sweep, %{interval: interval} = state) do
sweep()
Process.send_after(self(), :sweep, interval)
{:noreply, state}
end
def sweep do
expired = GuildSchematics.schematics_with_expired_overrides()
Enum.each(expired, fn gs ->
Logger.warning("Revoking expired founding override for schematic #{gs.schematic_name}")
case GuildSchematics.update_schematic(gs, %{
founding_override_revoked_at:
DateTime.utc_now() |> DateTime.truncate(:microsecond),
founding_override_revocation_reason: "ttl_expired"
}) do
{:ok, _} ->
slug = if gs.guild, do: gs.guild.slug, else: gs.schematic_name
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"override:#{slug}",
{:founding_override_revoked,
%{guild_schematic_id: gs.id, reason: "ttl_expired"}}
)
{:error, reason} ->
Logger.error("Failed to revoke override for #{gs.schematic_name}: #{inspect(reason)}")
end
end)
length(expired)
end
defp default_interval do
Application.get_env(:guildhall_orchestrator, :founding_override_sweep_interval_ms, 3_600_000)
end
end

View file

@ -0,0 +1,79 @@
defmodule Guildhall.Orchestrator.HostingCeremony do
@moduledoc false
alias Guildhall.OpsDb.{GuildMemberships, HostingAgreements}
def propose(guild_schematic, partner_guild) do
default_duration_days = 180
expires_at =
DateTime.utc_now()
|> DateTime.add(default_duration_days * 86_400, :second)
|> DateTime.truncate(:microsecond)
attrs = %{
guild_schematic_id: guild_schematic.id,
partner_guild_id: partner_guild.id,
hosting_did: partner_guild.registrant_did,
terms: %{
"duration_days" => default_duration_days,
"max_namespaces" => 1,
"handoff_trigger" => "manual"
},
status: "proposed",
expires_at: expires_at
}
case HostingAgreements.create(attrs) do
{:ok, ha} ->
if auto_ratify?(partner_guild, guild_schematic) do
HostingAgreements.update(ha, %{
status: "ratified",
auto_ratified: true,
partner_signed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond)
})
else
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"hosting:#{guild_schematic.schematic_name}",
{:hosting_proposed, %{hosting_agreement_id: ha.id}}
)
{:ok, ha}
end
error ->
error
end
end
def ratify(hosting_agreement, %{signer_did: signer_did, signature: signature}) do
partner_guild_id = hosting_agreement.partner_guild_id
masters = GuildMemberships.masters_for_guild(partner_guild_id)
is_master = Enum.any?(masters, fn m -> m.user_did == signer_did end)
if is_master do
case HostingAgreements.ratify(hosting_agreement, %{partner_signature: signature}) do
{:ok, ha} ->
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"hosting:#{hosting_agreement.guild_schematic_id}",
{:hosting_ratified, %{hosting_agreement_id: ha.id}}
)
{:ok, ha}
error ->
error
end
else
{:error, :not_partner_master}
end
end
defp auto_ratify?(partner_guild, _guild_schematic) do
guildhouse_slug =
Application.get_env(:guildhall_orchestrator, :guildhouse_partner_slug, "guildhouse-ops")
partner_guild.slug == guildhouse_slug
end
end

View file

@ -82,6 +82,41 @@ defmodule Guildhall.Orchestrator.SchematicClient do
end end
end end
@impl true
def get_ffc_schematic(name, version) do
request = %Schematic.V1.GetFfcSchematicRequest{name: name, version: version}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.get_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def approve_ffc_schematic(attrs) when is_map(attrs) do
request = %Schematic.V1.ApproveFfcSchematicRequest{
name: attrs.schematic_name,
version: attrs.schematic_version,
signer_did: attrs.signer_did,
role: attrs.role,
signature: attrs.signature,
accord_hash: attrs.accord_hash,
key_ref: attrs.key_ref || ""
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.approve_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
if response.accepted do
{:ok, response}
else
{:error, {:approval_rejected, response.message}}
end
end
end
defp connect do defp connect do
url = url =
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091") Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")

View file

@ -15,4 +15,10 @@ defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do
@callback get_realization_status(String.t()) :: @callback get_realization_status(String.t()) ::
{:ok, map()} | {:error, term()} {:ok, map()} | {:error, term()}
@callback get_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback approve_ffc_schematic(map()) ::
{:ok, map()} | {:error, term()}
end end

View file

@ -0,0 +1,56 @@
defmodule Mix.Tasks.Guildhall.DevApprove do
@moduledoc "Generate a test Ed25519 signature and POST to the local approval webhook."
@shortdoc "Dev-only: approve a pending schematic with a test signature"
use Mix.Task
@impl true
def run([name, version | rest]) do
Mix.Task.run("app.start")
signer_did = List.first(rest) || "did:web:guildhouse.dev:user:tking"
{pub, priv} = :crypto.generate_key(:eddsa, :ed25519)
hash = :crypto.strong_rand_bytes(32)
accord_hash = :crypto.strong_rand_bytes(32)
signature = :crypto.sign(:eddsa, :none, hash, [priv, :ed25519])
secret = Application.get_env(:guildhall_orchestrator, :approval_webhook_secret, "dev-secret")
port = Application.get_env(:guildhall_web, GuildhallWeb.Endpoint)[:http][:port] || 4000
body =
Jason.encode!(%{
schematic_name: name,
schematic_version: version,
signer_did: signer_did,
signature: Base.encode64(signature),
accord_hash: Base.encode64(accord_hash),
key_ref: "dev-key-#{Base.encode16(:binary.part(pub, 0, 4), case: :lower)}"
})
url = "http://localhost:#{port}/api/approvals"
Mix.shell().info("POSTing approval to #{url}")
Mix.shell().info("Signer: #{signer_did}")
Mix.shell().info("Public key: #{Base.encode16(pub, case: :lower)}")
case :httpc.request(
:post,
{~c"#{url}", [{~c"authorization", ~c"Bearer #{secret}"}, {~c"content-type", ~c"application/json"}], ~c"application/json", body},
[],
[]
) do
{:ok, {{_, status, _}, _headers, resp_body}} ->
Mix.shell().info("Response: #{status} #{resp_body}")
{:error, reason} ->
Mix.shell().error("Request failed: #{inspect(reason)}")
end
end
def run(_) do
Mix.shell().error("Usage: mix guildhall.dev_approve <schematic_name> <version> [signer_did]")
end
end

View file

@ -0,0 +1,46 @@
defmodule Mix.Tasks.Guildhall.DevRatifyHosting do
@moduledoc "Dev-only: ratify a hosting agreement with a test signature."
@shortdoc "Dev-only: ratify a hosting agreement"
use Mix.Task
@impl true
def run([id | rest]) do
Mix.Task.run("app.start")
signer_did = List.first(rest) || "did:web:guildhouse.dev:user:tking"
{_pub, priv} = :crypto.generate_key(:eddsa, :ed25519)
signature = :crypto.sign(:eddsa, :none, "ratify-#{id}", [priv, :ed25519])
secret = Application.get_env(:guildhall_orchestrator, :approval_webhook_secret, "dev-secret")
port = Application.get_env(:guildhall_web, GuildhallWeb.Endpoint)[:http][:port] || 4000
body =
Jason.encode!(%{
signer_did: signer_did,
signature: Base.encode64(signature)
})
url = "http://localhost:#{port}/api/hosting-agreements/#{id}/ratify"
Mix.shell().info("POSTing ratification to #{url}")
case :httpc.request(
:post,
{~c"#{url}", [{~c"authorization", ~c"Bearer #{secret}"}, {~c"content-type", ~c"application/json"}], ~c"application/json", body},
[],
[]
) do
{:ok, {{_, status, _}, _headers, resp_body}} ->
Mix.shell().info("Response: #{status} #{resp_body}")
{:error, reason} ->
Mix.shell().error("Request failed: #{inspect(reason)}")
end
end
def run(_) do
Mix.shell().error("Usage: mix guildhall.dev_ratify_hosting <agreement_id> [signer_did]")
end
end

View file

@ -0,0 +1,56 @@
defmodule Guildhall.Orchestrator.ApprovalPayloadTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.ApprovalPayload
defp valid_payload do
%ApprovalPayload{
schematic_name: "msp-test-guild",
schematic_version: "1.0.0",
signer_did: "did:web:guildhouse.dev:user:tking",
signature: :crypto.strong_rand_bytes(64),
accord_hash: :crypto.strong_rand_bytes(32),
key_ref: "key-1"
}
end
describe "validate/1" do
test "valid payload passes" do
assert :ok = ApprovalPayload.validate(valid_payload())
end
test "signature ≠ 64 bytes rejected" do
payload = %{valid_payload() | signature: :crypto.strong_rand_bytes(32)}
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
assert Enum.any?(errors, fn {field, _} -> field == :signature end)
end
test "empty signer_did rejected" do
payload = %{valid_payload() | signer_did: ""}
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
assert Enum.any?(errors, fn {field, _} -> field == :signer_did end)
end
test "signer_did without did: prefix rejected" do
payload = %{valid_payload() | signer_did: "web:guildhouse.dev:user:tking"}
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
assert Enum.any?(errors, fn {field, _} -> field == :signer_did end)
end
test "empty schematic_name rejected" do
payload = %{valid_payload() | schematic_name: ""}
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
assert Enum.any?(errors, fn {field, _} -> field == :schematic_name end)
end
test "hosting_agreement_id is optional" do
payload = %{valid_payload() | hosting_agreement_id: nil}
assert :ok = ApprovalPayload.validate(payload)
end
test "default role is FFC_ROLE_FOUNDING_STAKEHOLDER" do
payload = valid_payload()
assert payload.role == :FFC_ROLE_FOUNDING_STAKEHOLDER
end
end
end

View file

@ -0,0 +1,84 @@
defmodule Guildhall.Orchestrator.FoundingOverrideGuardTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.FoundingOverrideGuard
defp schematic_with_override(opts \\ []) do
%{
customization_params: %{
"ceremonies.governance_change.founding_override" => 1
},
founding_override_expires_at: Keyword.get(opts, :expires_at, future_time()),
founding_override_revoked_at: Keyword.get(opts, :revoked_at, nil),
founding_override_revocation_reason: Keyword.get(opts, :revocation_reason, nil)
}
end
defp future_time, do: DateTime.utc_now() |> DateTime.add(86_400, :second)
defp past_time, do: DateTime.utc_now() |> DateTime.add(-86_400, :second)
describe "check/1" do
test "active override (future expiry) → :ok" do
assert :ok = FoundingOverrideGuard.check(schematic_with_override())
end
test "expired override → error" do
gs = schematic_with_override(expires_at: past_time())
assert {:error, :override_ttl_expired} = FoundingOverrideGuard.check(gs)
end
test "revoked override → error with reason" do
gs =
schematic_with_override(
revoked_at: DateTime.utc_now(),
revocation_reason: "second_master_onboarded"
)
assert {:error, {:override_revoked, "second_master_onboarded"}} =
FoundingOverrideGuard.check(gs)
end
test "nil expiry with overrides → fail-closed" do
gs = schematic_with_override(expires_at: nil)
assert {:error, :override_missing_expiry} = FoundingOverrideGuard.check(gs)
end
test "no overrides → :ok" do
gs = %{customization_params: %{}}
assert :ok = FoundingOverrideGuard.check(gs)
end
test "nil expiry + empty overrides → :ok" do
gs = %{customization_params: %{}, founding_override_expires_at: nil}
assert :ok = FoundingOverrideGuard.check(gs)
end
test "missing customization_params → :ok" do
assert :ok = FoundingOverrideGuard.check(%{})
end
end
describe "effective_quorum/2" do
test "returns override value when active" do
gs = schematic_with_override()
assert {:override, 1} =
FoundingOverrideGuard.effective_quorum(gs, "governance_change")
end
test "returns :no_override when expired" do
gs = schematic_with_override(expires_at: past_time())
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "governance_change")
end
test "returns :no_override when no matching ceremony key" do
gs = schematic_with_override()
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "code_change")
end
test "returns :no_override when no overrides" do
gs = %{customization_params: %{}}
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "governance_change")
end
end
end

View file

@ -0,0 +1,59 @@
defmodule GuildhallWeb.ApprovalController do
use GuildhallWeb, :controller
alias Guildhall.Orchestrator.{ApprovalCoordinator, ApprovalPayload}
def create(conn, params) do
with :ok <- verify_bearer(conn),
{:ok, payload} <- build_payload(params) do
case ApprovalCoordinator.receive_approval(payload) do
{:ok, _state} ->
conn |> put_status(202) |> json(%{status: "accepted"})
{:error, reason} ->
conn |> put_status(422) |> json(%{error: inspect(reason)})
end
end
end
defp verify_bearer(conn) do
expected =
Application.get_env(:guildhall_orchestrator, :approval_webhook_secret)
case get_req_header(conn, "authorization") do
["Bearer " <> token] when token == expected and expected != nil ->
:ok
_ ->
conn
|> put_status(401)
|> json(%{error: "unauthorized"})
|> halt()
end
end
defp build_payload(params) do
with {:ok, signature} <- decode_base64(params["signature"], :signature),
{:ok, accord_hash} <- decode_base64(params["accord_hash"], :accord_hash) do
{:ok,
%ApprovalPayload{
schematic_name: params["schematic_name"] || "",
schematic_version: params["schematic_version"] || "",
signer_did: params["signer_did"] || "",
signature: signature,
accord_hash: accord_hash,
key_ref: params["key_ref"],
hosting_agreement_id: params["hosting_agreement_id"]
}}
end
end
defp decode_base64(nil, field), do: {:error, {field, "required"}}
defp decode_base64(value, _field) when is_binary(value) do
case Base.decode64(value) do
{:ok, bytes} -> {:ok, bytes}
:error -> {:error, {:base64, "invalid base64"}}
end
end
end

View file

@ -0,0 +1,53 @@
defmodule GuildhallWeb.HostingAgreementController do
use GuildhallWeb, :controller
alias Guildhall.OpsDb.HostingAgreements
alias Guildhall.Orchestrator.HostingCeremony
def ratify(conn, %{"id" => id} = params) do
with :ok <- verify_bearer(conn) do
ha = HostingAgreements.get!(id)
with {:ok, signature} <- decode_base64(params["signature"]) do
case HostingCeremony.ratify(ha, %{
signer_did: params["signer_did"] || "",
signature: signature
}) do
{:ok, _ha} ->
conn |> put_status(200) |> json(%{status: "ratified"})
{:error, reason} ->
conn |> put_status(422) |> json(%{error: inspect(reason)})
end
else
{:error, reason} ->
conn |> put_status(422) |> json(%{error: inspect(reason)})
end
end
end
defp verify_bearer(conn) do
expected =
Application.get_env(:guildhall_orchestrator, :approval_webhook_secret)
case get_req_header(conn, "authorization") do
["Bearer " <> token] when token == expected and expected != nil ->
:ok
_ ->
conn
|> put_status(401)
|> json(%{error: "unauthorized"})
|> halt()
end
end
defp decode_base64(nil), do: {:error, {:signature, "required"}}
defp decode_base64(value) when is_binary(value) do
case Base.decode64(value) do
{:ok, bytes} -> {:ok, bytes}
:error -> {:error, {:base64, "invalid base64"}}
end
end
end

View file

@ -16,7 +16,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do
else else
existing = GuildSchematics.get_for_guild(guild.id) existing = GuildSchematics.get_for_guild(guild.id)
if existing do if existing && existing.status not in ["draft", "awaiting_approval"] do
{:ok, {:ok,
socket socket
|> put_flash(:info, "Schematic already deployed.") |> put_flash(:info, "Schematic already deployed.")
@ -24,6 +24,12 @@ defmodule GuildhallWeb.GuildLive.Schematic do
else else
template_result = SchematicTemplate.load_template(guild.guild_type) template_result = SchematicTemplate.load_template(guild.guild_type)
awaiting = existing && existing.status == "awaiting_approval"
if awaiting do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "approval:#{slug}")
end
{:ok, {:ok,
socket socket
|> assign(:page_title, "#{guild.name} — Deploy Schematic") |> assign(:page_title, "#{guild.name} — Deploy Schematic")
@ -31,7 +37,11 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|> assign(:template, elem_ok(template_result)) |> assign(:template, elem_ok(template_result))
|> assign(:template_error, elem_error(template_result)) |> assign(:template_error, elem_error(template_result))
|> assign(:deploying, false) |> assign(:deploying, false)
|> assign(:deploy_error, nil)} |> assign(:deploy_error, nil)
|> assign(:awaiting_approval, awaiting)
|> assign(:schematic_hash, nil)
|> assign(:guild_schematic, existing)
|> assign(:bootstrap_mode, (existing && existing.bootstrap_mode) || "self_sovereign")}
end end
end end
end end
@ -42,13 +52,22 @@ defmodule GuildhallWeb.GuildLive.Schematic do
socket = assign(socket, :deploying, true) socket = assign(socket, :deploying, true)
case FfcPipeline.deploy(guild) do case FfcPipeline.deploy(guild) do
{:ok, _state} -> {:paused, state} ->
{:ok, _} = Guilds.update_guild(guild, %{status: "active"}) hash_hex =
if state.schematic_hash,
do: Base.encode16(state.schematic_hash, case: :lower),
else: "unavailable"
Phoenix.PubSub.subscribe(Guildhall.PubSub, "approval:#{guild.slug}")
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Schematic deployed. Realization in progress.") |> assign(:deploying, false)
|> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")} |> assign(:awaiting_approval, true)
|> assign(:schematic_hash, hash_hex)
|> assign(:guild_schematic, state.guild_schematic)
|> assign(:bootstrap_mode, state.bootstrap_mode)
|> put_flash(:info, "Schematic created. Awaiting approval signature.")}
{:error, {step, reason}} -> {:error, {step, reason}} ->
{:noreply, {:noreply,
@ -58,6 +77,16 @@ defmodule GuildhallWeb.GuildLive.Schematic do
end end
end end
@impl true
def handle_info({:approval_complete, _payload}, socket) do
guild = socket.assigns.guild
{:noreply,
socket
|> put_flash(:info, "Schematic approved. Realization in progress.")
|> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")}
end
defp elem_ok({:ok, val}), do: val defp elem_ok({:ok, val}), do: val
defp elem_ok(_), do: nil defp elem_ok(_), do: nil
@ -77,7 +106,32 @@ defmodule GuildhallWeb.GuildLive.Schematic do
Failed to load template: {@template_error} Failed to load template: {@template_error}
</div> </div>
<section :if={@template} class="space-y-4"> <section :if={@awaiting_approval} class="space-y-4">
<div class="border border-amber-200 bg-amber-50 rounded p-4 text-sm space-y-3">
<p class="font-semibold text-amber-800">Awaiting Approval Signature</p>
<p class="text-amber-700">
The founding schematic has been created and validated.
A founding stakeholder must sign it to proceed.
</p>
<div :if={@schematic_hash} class="space-y-1">
<p class="text-xs text-amber-600 font-semibold">Schematic hash (sign this):</p>
<code class="block p-2 bg-white rounded border border-amber-200 text-xs font-mono break-all">
{@schematic_hash}
</code>
</div>
<div class="text-xs text-amber-600 mt-2">
<p :if={@bootstrap_mode == "self_sovereign"}>
Sign from your governed shell:
<code class="bg-white px-1 rounded">gsh orgops sign-schematic --hash {@schematic_hash || "..."}</code>
</p>
<p :if={@bootstrap_mode == "partner_hosted"}>
Your hosting partner will provide a signing context.
</p>
</div>
</div>
</section>
<section :if={@template && !@awaiting_approval} class="space-y-4">
<div class="text-sm space-y-2"> <div class="text-sm space-y-2">
<p> <p>
Template: <span class="font-mono font-semibold">{@guild.guild_type}-founding</span> Template: <span class="font-mono font-semibold">{@guild.guild_type}-founding</span>
@ -91,9 +145,9 @@ defmodule GuildhallWeb.GuildLive.Schematic do
<p>This will:</p> <p>This will:</p>
<ol class="list-decimal ml-5 space-y-1"> <ol class="list-decimal ml-5 space-y-1">
<li>Validate and resolve the founding schematic template</li> <li>Validate and resolve the founding schematic template</li>
<li>Create, validate, and publish the FFC schematic</li> <li>Create and validate the FFC schematic on the server</li>
<li>Trigger realization across all reconciler sections</li> <li>Pause for founding stakeholder approval signature</li>
<li>Transition guild to <span class="font-semibold">active</span> status</li> <li>After approval: publish, realize, and transition guild to <span class="font-semibold">active</span></li>
</ol> </ol>
</div> </div>

View file

@ -19,13 +19,21 @@ defmodule GuildhallWeb.GuildLive.Show do
end end
end end
schematic = GuildSchematics.get_for_guild(guild.id)
is_founding_master = socket.assigns.current_user["did"] == guild.registrant_did
if connected?(socket) && schematic do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "override:#{slug}")
end
{:ok, {:ok,
socket socket
|> assign(:page_title, guild.name) |> assign(:page_title, guild.name)
|> assign(:guild, guild) |> assign(:guild, guild)
|> assign(:ceremony_status, nil) |> assign(:ceremony_status, nil)
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did) |> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)
|> assign(:schematic, GuildSchematics.get_for_guild(guild.id)) |> assign(:is_founding_master, is_founding_master)
|> assign(:schematic, schematic)
|> assign(:member_count, length(GuildMemberships.active_members(guild.id)))} |> assign(:member_count, length(GuildMemberships.active_members(guild.id)))}
end end
@ -66,6 +74,17 @@ defmodule GuildhallWeb.GuildLive.Show do
{:noreply, assign(socket, :guild, guild)} {:noreply, assign(socket, :guild, guild)}
end end
@impl true
def handle_info({:founding_override_revoked, _payload}, socket) do
guild = socket.assigns.guild
schematic = GuildSchematics.get_for_guild(guild.id)
{:noreply,
socket
|> assign(:schematic, schematic)
|> put_flash(:info, "Founding override has been revoked.")}
end
@impl true @impl true
def handle_info(_msg, socket), do: {:noreply, socket} def handle_info(_msg, socket), do: {:noreply, socket}
@ -119,6 +138,31 @@ defmodule GuildhallWeb.GuildLive.Show do
end end
end end
@impl true
def handle_event("revoke_override", _params, socket) do
guild = socket.assigns.guild
case GuildSchematics.revoke_founding_override(guild.id, "manual_revocation") do
{:ok, _} ->
schematic = GuildSchematics.get_for_guild(guild.id)
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"override:#{guild.slug}",
{:founding_override_revoked,
%{guild_id: guild.id, reason: "manual_revocation"}}
)
{:noreply,
socket
|> assign(:schematic, schematic)
|> put_flash(:info, "Founding override revoked.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Revocation failed: #{inspect(reason)}")}
end
end
defp status_class("pending_approval"), do: "bg-amber-100 text-amber-700" defp status_class("pending_approval"), do: "bg-amber-100 text-amber-700"
defp status_class("approved"), do: "bg-emerald-100 text-emerald-700" defp status_class("approved"), do: "bg-emerald-100 text-emerald-700"
defp status_class("active"), do: "bg-emerald-100 text-emerald-700" defp status_class("active"), do: "bg-emerald-100 text-emerald-700"
@ -128,9 +172,31 @@ defmodule GuildhallWeb.GuildLive.Show do
defp schematic_status_class("realized"), do: "text-emerald-600" defp schematic_status_class("realized"), do: "text-emerald-600"
defp schematic_status_class("realizing"), do: "text-amber-600" defp schematic_status_class("realizing"), do: "text-amber-600"
defp schematic_status_class("awaiting_approval"), do: "text-amber-600"
defp schematic_status_class("failed"), do: "text-red-600" defp schematic_status_class("failed"), do: "text-red-600"
defp schematic_status_class(_), do: "text-zinc-500" defp schematic_status_class(_), do: "text-zinc-500"
defp override_days_remaining(schematic) do
if schematic && schematic.founding_override_expires_at do
diff = DateTime.diff(schematic.founding_override_expires_at, DateTime.utc_now(), :second)
max(0, div(diff, 86_400))
else
0
end
end
defp override_status(schematic) do
cond do
is_nil(schematic) -> nil
!is_map(schematic.customization_params) -> nil
map_size(schematic.customization_params) == 0 -> nil
schematic.founding_override_revoked_at -> {:revoked, schematic.founding_override_revocation_reason}
is_nil(schematic.founding_override_expires_at) -> nil
DateTime.compare(DateTime.utc_now(), schematic.founding_override_expires_at) == :gt -> :expired
true -> {:active, override_days_remaining(schematic)}
end
end
defp type_label("msp"), do: "Managed Service Provider" defp type_label("msp"), do: "Managed Service Provider"
defp type_label("isv"), do: "Independent Software Vendor" defp type_label("isv"), do: "Independent Software Vendor"
defp type_label("nsp"), do: "Network Service Provider" defp type_label("nsp"), do: "Network Service Provider"
@ -201,10 +267,10 @@ defmodule GuildhallWeb.GuildLive.Show do
</div> </div>
<div class="flex flex-wrap gap-3 text-sm"> <div class="flex flex-wrap gap-3 text-sm">
<.link :if={@schematic == nil} navigate={~p"/guilds/#{@guild.slug}/schematic"} class="rounded bg-blue-600 px-3 py-1.5 text-white"> <.link :if={@schematic == nil || @schematic.status in ["draft", "awaiting_approval"]} navigate={~p"/guilds/#{@guild.slug}/schematic"} class="rounded bg-blue-600 px-3 py-1.5 text-white">
Deploy Schematic {if @schematic && @schematic.status == "awaiting_approval", do: "Approve Schematic", else: "Deploy Schematic"}
</.link> </.link>
<.link :if={@schematic} navigate={~p"/guilds/#{@guild.slug}/realization"} class="rounded bg-zinc-700 px-3 py-1.5 text-white"> <.link :if={@schematic && @schematic.status not in ["draft", "awaiting_approval"]} navigate={~p"/guilds/#{@guild.slug}/realization"} class="rounded bg-zinc-700 px-3 py-1.5 text-white">
Realization Dashboard Realization Dashboard
</.link> </.link>
<.link navigate={~p"/guilds/#{@guild.slug}/join"} class="rounded border border-blue-600 px-3 py-1.5 text-blue-600"> <.link navigate={~p"/guilds/#{@guild.slug}/join"} class="rounded border border-blue-600 px-3 py-1.5 text-blue-600">
@ -215,10 +281,53 @@ defmodule GuildhallWeb.GuildLive.Show do
</.link> </.link>
</div> </div>
<div :if={@schematic} class="rounded border border-zinc-200 p-3 text-sm"> <div :if={@schematic} class="rounded border border-zinc-200 p-3 text-sm space-y-2">
<div class="text-zinc-500 text-xs mb-1">Schematic</div> <div class="flex items-center justify-between">
<span class="font-mono">{@schematic.schematic_name} v{@schematic.schematic_version}</span> <div>
<span class={"ml-2 text-xs #{schematic_status_class(@schematic.status)}"}>{@schematic.status}</span> <div class="text-zinc-500 text-xs mb-1">Schematic</div>
<span class="font-mono">{@schematic.schematic_name} v{@schematic.schematic_version}</span>
<span class={"ml-2 text-xs #{schematic_status_class(@schematic.status)}"}>{@schematic.status}</span>
</div>
<span :if={@schematic.bootstrap_mode == "partner_hosted"} class="text-xs bg-sky-100 text-sky-700 rounded-full px-2 py-0.5">
partner-hosted
</span>
</div>
<%= case override_status(@schematic) do %>
<% {:active, days} -> %>
<div class="flex items-center justify-between border-t border-zinc-100 pt-2">
<span class="text-xs bg-emerald-100 text-emerald-700 rounded-full px-2 py-0.5">
Override active ({days} days remaining)
</span>
<button
:if={@is_founding_master}
phx-click="revoke_override"
data-confirm="Revoke founding override? This cannot be undone."
class="text-xs text-red-600 underline"
>
Revoke Override
</button>
</div>
<% {:revoked, reason} -> %>
<div class="border-t border-zinc-100 pt-2">
<span class="text-xs bg-zinc-100 text-zinc-600 rounded-full px-2 py-0.5">
Override revoked ({reason || "unknown"})
</span>
</div>
<% :expired -> %>
<div class="border-t border-zinc-100 pt-2">
<span class="text-xs bg-red-100 text-red-700 rounded-full px-2 py-0.5">
Override expired
</span>
</div>
<% _ -> %>
<% end %>
</div>
<div :if={@schematic && @schematic.status == "awaiting_approval"} class="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
<.link navigate={~p"/guilds/#{@guild.slug}/schematic"} class="underline">
Awaiting founding stakeholder signature
</.link>
</div> </div>
</section> </section>
</div> </div>

View file

@ -44,6 +44,13 @@ defmodule GuildhallWeb.Router do
end end
end end
# Machine-to-machine API endpoints (bearer token auth)
scope "/api", GuildhallWeb do
pipe_through :api
post "/approvals", ApprovalController, :create
post "/hosting-agreements/:id/ratify", HostingAgreementController, :ratify
end
# Health check endpoint for Kubernetes probes + LB targets. # Health check endpoint for Kubernetes probes + LB targets.
scope "/health", GuildhallWeb do scope "/health", GuildhallWeb do
pipe_through :api pipe_through :api

View file

@ -54,4 +54,8 @@ config :guildhall_web, :oidc,
config :guildhall_orchestrator, config :guildhall_orchestrator,
ceremony_service_url: "localhost:50053", ceremony_service_url: "localhost:50053",
schematic_service_url: "localhost:9091", schematic_service_url: "localhost:9091",
ffc_schematic_service_url: "localhost:9091" ffc_schematic_service_url: "localhost:9091",
founding_override_sweep_interval_ms: 3_600_000,
approval_webhook_secret: "dev-secret",
guildhouse_partner_slug: "guildhouse-ops",
guildhouse_auto_ratify_tiers: ["nsp"]