From 38cf2b7c6b2081a9608a6ab0ae40ba3e0573eacadb4221f2614cabd36f70058a Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sat, 16 May 2026 12:54:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20governance=20correctness?= =?UTF-8?q?=20=E2=80=94=20override=20revocation=20+=20bootstrap=20ceremony?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Tyler J King --- CLAUDE.md | 32 +++- .../lib/guildhall/ops_db/guild_memberships.ex | 51 ++++-- .../lib/guildhall/ops_db/guild_schematic.ex | 21 ++- .../lib/guildhall/ops_db/guild_schematics.ex | 42 +++++ .../lib/guildhall/ops_db/hosting_agreement.ex | 51 ++++++ .../guildhall/ops_db/hosting_agreements.ex | 44 +++++ ..._add_override_revocation_and_bootstrap.exs | 30 ++++ apps/guildhall_ops_db/priv/repo/seeds.exs | 21 +++ .../lib/guildhall/orchestrator/application.ex | 4 +- .../orchestrator/approval_coordinator.ex | 97 ++++++++++ .../orchestrator/approval_payload.ex | 52 ++++++ .../guildhall/orchestrator/ffc_pipeline.ex | 170 ++++++++++++++---- .../orchestrator/founding_override_guard.ex | 46 +++++ .../orchestrator/founding_override_monitor.ex | 59 ++++++ .../orchestrator/hosting_ceremony.ex | 79 ++++++++ .../orchestrator/schematic_client.ex | 35 ++++ .../schematic_client/behaviour.ex | 6 + .../lib/mix/tasks/guildhall.dev_approve.ex | 56 ++++++ .../mix/tasks/guildhall.dev_ratify_hosting.ex | 46 +++++ .../orchestrator/approval_payload_test.exs | 56 ++++++ .../founding_override_guard_test.exs | 84 +++++++++ .../controllers/approval_controller.ex | 59 ++++++ .../hosting_agreement_controller.ex | 53 ++++++ .../live/guild_live/schematic.ex | 74 ++++++-- .../guildhall_web_web/live/guild_live/show.ex | 125 ++++++++++++- .../lib/guildhall_web_web/router.ex | 7 + config/dev.exs | 6 +- 27 files changed, 1335 insertions(+), 71 deletions(-) create mode 100644 apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreement.ex create mode 100644 apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreements.ex create mode 100644 apps/guildhall_ops_db/priv/repo/migrations/20260516140000_add_override_revocation_and_bootstrap.exs create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_coordinator.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_payload.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_guard.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_monitor.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/hosting_ceremony.ex create mode 100644 apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_approve.ex create mode 100644 apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_ratify_hosting.ex create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/approval_payload_test.exs create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/founding_override_guard_test.exs create mode 100644 apps/guildhall_web/lib/guildhall_web_web/controllers/approval_controller.ex create mode 100644 apps/guildhall_web/lib/guildhall_web_web/controllers/hosting_agreement_controller.ex diff --git a/CLAUDE.md b/CLAUDE.md index f5e92cf..72c034f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,9 @@ Pre-existing: `governed_artifacts`, `deployment_states`, `verification_results` New guild tables: - **guilds** — `guild_id` (10-bit, 0x010–0x3FF), `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) +- **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 @@ -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` 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 -1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type from `priv/schematic_templates/` -2. Click Deploy → `SchematicClient.fork_schematic` → `create_deployment_binding` → `realize_ffc_schematic` -3. Creates `guild_schematics` row, starts `RealizationPoller.watch/2` -4. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates +### Schematic deployment (FfcPipeline) +1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type +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. Pipeline pauses at `awaiting_approval` — returns `{:paused, state}` with `schematic_hash` for signing +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 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` +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 - `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.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 diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_memberships.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_memberships.ex index c7bd4c8..83a7c3d 100644 --- a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_memberships.ex +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_memberships.ex @@ -31,13 +31,23 @@ defmodule Guildhall.OpsDb.GuildMemberships do end def approve_membership(%GuildMembership{} = membership, approver_did) do - membership - |> GuildMembership.changeset(%{ - status: "active", - approved_by_did: approver_did, - approved_at: DateTime.utc_now() - }) - |> Repo.update() + result = + membership + |> GuildMembership.changeset(%{ + status: "active", + approved_by_did: approver_did, + approved_at: DateTime.utc_now() + }) + |> Repo.update() + + case result do + {:ok, m} -> + maybe_revoke_founding_override(m.guild_id) + {:ok, m} + + error -> + error + end end def deny_membership(%GuildMembership{} = membership, approver_did) do @@ -51,8 +61,29 @@ defmodule Guildhall.OpsDb.GuildMemberships do end def update_role(%GuildMembership{} = membership, new_role) do - membership - |> GuildMembership.changeset(%{role: new_role}) - |> Repo.update() + result = + membership + |> 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 diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex index 4da0d59..c1d960c 100644 --- a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex @@ -17,10 +17,20 @@ defmodule Guildhall.OpsDb.GuildSchematic do field :customization_params, :map, default: %{} field :realization_snapshot, :map, default: %{} 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) end + @statuses ~w(pending draft validated hosting_agreed awaiting_approval approved published realizing realized partially_realized failed) + def changeset(schematic, attrs) do schematic |> cast(attrs, [ @@ -34,10 +44,17 @@ defmodule Guildhall.OpsDb.GuildSchematic do :status, :customization_params, :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_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) |> unique_constraint([:schematic_name, :schematic_version]) end diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematics.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematics.ex index cb69147..d31d705 100644 --- a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematics.ex +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematics.ex @@ -27,4 +27,46 @@ defmodule Guildhall.OpsDb.GuildSchematics do def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do update_schematic(gs, %{realization_snapshot: snapshot}) 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 diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreement.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreement.ex new file mode 100644 index 0000000..41c34f0 --- /dev/null +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreement.ex @@ -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 diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreements.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreements.ex new file mode 100644 index 0000000..cf488ae --- /dev/null +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/hosting_agreements.ex @@ -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 diff --git a/apps/guildhall_ops_db/priv/repo/migrations/20260516140000_add_override_revocation_and_bootstrap.exs b/apps/guildhall_ops_db/priv/repo/migrations/20260516140000_add_override_revocation_and_bootstrap.exs new file mode 100644 index 0000000..5caf029 --- /dev/null +++ b/apps/guildhall_ops_db/priv/repo/migrations/20260516140000_add_override_revocation_and_bootstrap.exs @@ -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 diff --git a/apps/guildhall_ops_db/priv/repo/seeds.exs b/apps/guildhall_ops_db/priv/repo/seeds.exs index 8b15b17..cf1ca4e 100644 --- a/apps/guildhall_ops_db/priv/repo/seeds.exs +++ b/apps/guildhall_ops_db/priv/repo/seeds.exs @@ -6,6 +6,7 @@ alias Guildhall.OpsDb.{ Repo, + Guild, AccordBinding, GovernedArtifact, CustodyTransition, @@ -15,6 +16,26 @@ alias Guildhall.OpsDb.{ 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) # ──────────────────────────────────────────────────────── diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/application.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/application.ex index 85639aa..1602335 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/application.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/application.ex @@ -6,7 +6,9 @@ defmodule Guildhall.Orchestrator.Application do def start(_type, _args) do children = [ Guildhall.Orchestrator.CeremonyOrchestrator, - Guildhall.Orchestrator.RealizationPoller + Guildhall.Orchestrator.RealizationPoller, + Guildhall.Orchestrator.FoundingOverrideMonitor, + Guildhall.Orchestrator.ApprovalCoordinator ] opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor] diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_coordinator.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_coordinator.ex new file mode 100644 index 0000000..c82ec1c --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_coordinator.ex @@ -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 diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_payload.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_payload.ex new file mode 100644 index 0000000..0b772d4 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/approval_payload.ex @@ -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 diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex index e8c1dfd..cd8275f 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex @@ -5,8 +5,8 @@ defmodule Guildhall.Orchestrator.FfcPipeline do alias Guildhall.Orchestrator.SchematicTemplate alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder} - alias Guildhall.Orchestrator.RealizationPoller - alias Guildhall.OpsDb.GuildSchematics + alias Guildhall.Orchestrator.{FoundingOverrideGuard, RealizationPoller} + alias Guildhall.OpsDb.{GuildSchematics, HostingAgreements} defstruct [ :guild, @@ -19,13 +19,17 @@ defmodule Guildhall.Orchestrator.FfcPipeline do :schematic_name, :schematic_version, :guild_schematic, - :realization_id + :realization_id, + :schematic_hash, + :accord_hash, + :bootstrap_mode ] 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}") + bootstrap_mode = Keyword.get(opts, :bootstrap_mode, "self_sovereign") params = %{ "trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev", @@ -38,7 +42,8 @@ defmodule Guildhall.Orchestrator.FfcPipeline do guild: guild, guild_type: guild_type, 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), @@ -48,12 +53,31 @@ defmodule Guildhall.Orchestrator.FfcPipeline do {: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} + {:ok, state} <- step(:fetch_schematic_hash, state, fn -> fetch_schematic_hash(state) end), + {: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 @@ -124,25 +148,6 @@ defmodule Guildhall.Orchestrator.FfcPipeline do 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) @@ -159,10 +164,10 @@ defmodule Guildhall.Orchestrator.FfcPipeline do template_name: "#{state.guild_type}-founding", schematic_name: state.schematic_name, schematic_version: state.schematic_version, - realization_id: state.realization_id, - status: "realizing", + status: "draft", 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 @@ -171,8 +176,107 @@ defmodule Guildhall.Orchestrator.FfcPipeline do end end - defp start_poller(%{realization_id: realization_id, guild: guild} = state) do - RealizationPoller.watch(realization_id, guild.slug) + defp fetch_schematic_hash(%{schematic_name: name, schematic_version: version} = state) do + 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} end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_guard.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_guard.ex new file mode 100644 index 0000000..d2c6bca --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_guard.ex @@ -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 diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_monitor.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_monitor.ex new file mode 100644 index 0000000..a789183 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/founding_override_monitor.ex @@ -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 diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/hosting_ceremony.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/hosting_ceremony.ex new file mode 100644 index 0000000..311a8ad --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/hosting_ceremony.ex @@ -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 diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex index 857c17c..beef301 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex @@ -82,6 +82,41 @@ defmodule Guildhall.Orchestrator.SchematicClient do 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 url = Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091") diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex index ca024f6..09b7b3c 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex @@ -15,4 +15,10 @@ defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do @callback get_realization_status(String.t()) :: {: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 diff --git a/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_approve.ex b/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_approve.ex new file mode 100644 index 0000000..7348c85 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_approve.ex @@ -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 [signer_did]") + end +end diff --git a/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_ratify_hosting.ex b/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_ratify_hosting.ex new file mode 100644 index 0000000..b4299cc --- /dev/null +++ b/apps/guildhall_orchestrator/lib/mix/tasks/guildhall.dev_ratify_hosting.ex @@ -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 [signer_did]") + end +end diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/approval_payload_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/approval_payload_test.exs new file mode 100644 index 0000000..560a0ea --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/approval_payload_test.exs @@ -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 diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/founding_override_guard_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/founding_override_guard_test.exs new file mode 100644 index 0000000..55f5c7d --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/founding_override_guard_test.exs @@ -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 diff --git a/apps/guildhall_web/lib/guildhall_web_web/controllers/approval_controller.ex b/apps/guildhall_web/lib/guildhall_web_web/controllers/approval_controller.ex new file mode 100644 index 0000000..89c8bab --- /dev/null +++ b/apps/guildhall_web/lib/guildhall_web_web/controllers/approval_controller.ex @@ -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 diff --git a/apps/guildhall_web/lib/guildhall_web_web/controllers/hosting_agreement_controller.ex b/apps/guildhall_web/lib/guildhall_web_web/controllers/hosting_agreement_controller.ex new file mode 100644 index 0000000..0269f72 --- /dev/null +++ b/apps/guildhall_web/lib/guildhall_web_web/controllers/hosting_agreement_controller.ex @@ -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 diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex index 754800e..924f250 100644 --- a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex +++ b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex @@ -16,7 +16,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do else existing = GuildSchematics.get_for_guild(guild.id) - if existing do + if existing && existing.status not in ["draft", "awaiting_approval"] do {:ok, socket |> put_flash(:info, "Schematic already deployed.") @@ -24,6 +24,12 @@ defmodule GuildhallWeb.GuildLive.Schematic do else 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, socket |> assign(:page_title, "#{guild.name} — Deploy Schematic") @@ -31,7 +37,11 @@ defmodule GuildhallWeb.GuildLive.Schematic do |> assign(:template, elem_ok(template_result)) |> assign(:template_error, elem_error(template_result)) |> 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 @@ -42,13 +52,22 @@ defmodule GuildhallWeb.GuildLive.Schematic do socket = assign(socket, :deploying, true) case FfcPipeline.deploy(guild) do - {:ok, _state} -> - {:ok, _} = Guilds.update_guild(guild, %{status: "active"}) + {:paused, state} -> + 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, socket - |> put_flash(:info, "Schematic deployed. Realization in progress.") - |> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")} + |> assign(:deploying, false) + |> 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}} -> {:noreply, @@ -58,6 +77,16 @@ defmodule GuildhallWeb.GuildLive.Schematic do 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(_), do: nil @@ -77,7 +106,32 @@ defmodule GuildhallWeb.GuildLive.Schematic do Failed to load template: {@template_error} -
+
+
+

Awaiting Approval Signature

+

+ The founding schematic has been created and validated. + A founding stakeholder must sign it to proceed. +

+
+

Schematic hash (sign this):

+ + {@schematic_hash} + +
+
+

+ Sign from your governed shell: + gsh orgops sign-schematic --hash {@schematic_hash || "..."} +

+

+ Your hosting partner will provide a signing context. +

+
+
+
+ +

Template: {@guild.guild_type}-founding @@ -91,9 +145,9 @@ defmodule GuildhallWeb.GuildLive.Schematic do

This will:

  1. Validate and resolve the founding schematic template
  2. -
  3. Create, validate, and publish the FFC schematic
  4. -
  5. Trigger realization across all reconciler sections
  6. -
  7. Transition guild to active status
  8. +
  9. Create and validate the FFC schematic on the server
  10. +
  11. Pause for founding stakeholder approval signature
  12. +
  13. After approval: publish, realize, and transition guild to active
diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex index 8c4a113..eabf10f 100644 --- a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex +++ b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex @@ -19,13 +19,21 @@ defmodule GuildhallWeb.GuildLive.Show do 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, socket |> assign(:page_title, guild.name) |> assign(:guild, guild) |> assign(:ceremony_status, nil) |> 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)))} end @@ -66,6 +74,17 @@ defmodule GuildhallWeb.GuildLive.Show do {:noreply, assign(socket, :guild, guild)} 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 def handle_info(_msg, socket), do: {:noreply, socket} @@ -119,6 +138,31 @@ defmodule GuildhallWeb.GuildLive.Show do 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("approved"), 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("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(_), 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("isv"), do: "Independent Software Vendor" defp type_label("nsp"), do: "Network Service Provider" @@ -201,10 +267,10 @@ defmodule GuildhallWeb.GuildLive.Show do
- <.link :if={@schematic == nil} navigate={~p"/guilds/#{@guild.slug}/schematic"} class="rounded bg-blue-600 px-3 py-1.5 text-white"> - Deploy Schematic + <.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"> + {if @schematic && @schematic.status == "awaiting_approval", do: "Approve Schematic", else: "Deploy Schematic"} - <.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 <.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
-
-
Schematic
- {@schematic.schematic_name} v{@schematic.schematic_version} - {@schematic.status} +
+
+
+
Schematic
+ {@schematic.schematic_name} v{@schematic.schematic_version} + {@schematic.status} +
+ + partner-hosted + +
+ + <%= case override_status(@schematic) do %> + <% {:active, days} -> %> +
+ + Override active ({days} days remaining) + + +
+ <% {:revoked, reason} -> %> +
+ + Override revoked ({reason || "unknown"}) + +
+ <% :expired -> %> +
+ + Override expired + +
+ <% _ -> %> + <% end %> +
+ +
+ <.link navigate={~p"/guilds/#{@guild.slug}/schematic"} class="underline"> + Awaiting founding stakeholder signature +
diff --git a/apps/guildhall_web/lib/guildhall_web_web/router.ex b/apps/guildhall_web/lib/guildhall_web_web/router.ex index 775feb9..181bb4d 100644 --- a/apps/guildhall_web/lib/guildhall_web_web/router.ex +++ b/apps/guildhall_web/lib/guildhall_web_web/router.ex @@ -44,6 +44,13 @@ defmodule GuildhallWeb.Router do 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. scope "/health", GuildhallWeb do pipe_through :api diff --git a/config/dev.exs b/config/dev.exs index aa5ac20..a2d137f 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -54,4 +54,8 @@ config :guildhall_web, :oidc, config :guildhall_orchestrator, ceremony_service_url: "localhost:50053", 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"]