Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .machine_readable/merge-orchestration/LEDGER.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ hold = "6a2/STATE,META + anchors/ANCHOR in {hypatia,gitbot-fleet,.git-p
other-agents = "claude/nice-ptolemy-lNB8E (standards), claude/loving-cannon-AtUSm (echidna), claude/abi-tier2-and-l10-trans-cycle (vcl-ut), claude/affinescript-migration-wip (reposystem)"

[next]
step = "Wave-0 design COMPLETE (a1-a5). RUNTIME: increment 1 = Strategist (decision head); increment 2 = Kin.Council (competence-weighted veto aggregation over a3 attestations, NOT Byzantine; wired into Strategist, replacing the mean stub). 15 ExUnit tests total (6 council + 9 strategist), 0 failures, local elixir 1.14. NEXT INCREMENTS: increment 3 = .git-private-farm actuator (read decision -> re-verify invariants -> execute merge method); increment 4 = wire Strategist/Council into dispatch_manifest + triangle_router + Kin.Gate. Owner follow-ons: required-checks per repo; a5 standards adoption."
step = "Wave-0 design COMPLETE (a1-a5). RUNTIME COMPLETE (4 increments): 1 Strategist (decision head) + 2 Kin.Council (competence-weighted veto) + 4 Dispatcher (wiring: decision -> DispatchManifest tier/strategy vocabulary + Kin.Gate lease) [hypatia, 20 ExUnit, 0 failures]; 3 actuator (independent re-verify + execute, 5 bash checks) [.git-private-farm, merged #77]. spec -> running-code path complete end-to-end, all locally tested on elixir 1.14. REMAINING (operationalisation, not design): connect Dispatcher's thin glue into pattern_analyzer/fleet_dispatcher (the DispatchManifest.write call) + real Kin.Gate lease storage + GoT/MoE weight wiring into KinCouncil; owner follow-ons (required-checks per repo; a5 standards adoption; P3 handshake + mass_squash in actuator; the .git-private-farm Actions billing/CI is dead -- fix before the actuator can run scheduled)."
81 changes: 81 additions & 0 deletions lib/merge_orchestration/dispatcher.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.MergeOrchestration.Dispatcher do
@moduledoc """
Wiring — the merge-orchestration runtime's nervous system.

It connects a `Strategist` decision (route × method × safety) to hypatia's
*existing* dispatch pipeline by mapping it onto `Hypatia.DispatchManifest`'s
entry shape — the `tier` / `strategy` vocabulary (`eliminate|substitute|control`
× `auto_execute|review|report_only`) that the dispatch-runner and the
`.git-private-farm` actuator already consume — and it mints the Kin.Gate
coordination lease (artifact 5) for the PR's territory.

Pure: it returns the manifest entry + lease as data. The I/O (writing the
manifest via `DispatchManifest.write/1`, acquiring the lease in `Kin.Gate`) is
the caller's thin glue, so this stays test-isolated.
"""

alias Hypatia.MergeOrchestration.Strategist

@doc "Decide, then return `{decision, manifest_entry, lease}`."
def dispatch(ctx) do
decision = Strategist.decide(ctx)
{decision, to_manifest_entry(decision), lease_for(ctx, decision)}
end

@doc "Map a decision onto the existing DispatchManifest entry shape, plus a `merge` extension."
def to_manifest_entry(decision) do
%{
"tier" => tier(decision.safety),
"strategy" => strategy(decision.safety),
"repo" => decision.pr.repo,
"confidence" => decision.confidence,
"severity" => "Info",
"timestamp" => now(),
"merge" => %{
"pr_number" => decision.pr.number,
"method" => to_string(decision.method),
"safety" => to_string(decision.safety),
"route" => decision.route,
"vetoes" => decision.vetoes,
"change_class" => to_string(decision.change_class),
"change_level" => to_string(decision.change_level),
"pool" => to_string(decision.pool),
"rationale" => decision.rationale
}
}
end

@doc "Mint the Kin.Gate coordination lease (artifact-5 schema) for this decision's territory."
def lease_for(ctx, decision) do
%{
"lease_id" => "lease-" <> String.replace(decision.pr.repo, "/", "-") <> "-" <> to_string(decision.pr.number),
"holder" => Map.get(ctx, :holder, "hypatia"),
"repo" => decision.pr.repo,
"territory" => %{
"branch" => Map.get(ctx, :branch, "main"),
"paths" => Map.get(ctx, :paths, []),
"is_meta" => decision.change_level == :meta
},
"intent" => "merge-orchestration decision",
"status" => "held",
"acquired_at" => now(),
"expires_at" => later(3600),
# The dispatcher never self-authorises a meta claim (LE2); owner must.
"owner_authorized" => Map.get(ctx, :owner_authorized, false)
}
end

# safety axis -> the existing safety-triangle dispatch vocabulary
defp strategy(:arm_auto), do: "auto_execute"
defp strategy(:review), do: "review"
defp strategy(:flag), do: "report_only"

defp tier(:arm_auto), do: "eliminate"
defp tier(:review), do: "substitute"
defp tier(:flag), do: "control"

defp now, do: DateTime.utc_now() |> DateTime.to_iso8601()
defp later(secs), do: DateTime.utc_now() |> DateTime.add(secs, :second) |> DateTime.to_iso8601()
end
56 changes: 56 additions & 0 deletions test/merge_orchestration/dispatcher_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.MergeOrchestration.DispatcherTest do
use ExUnit.Case, async: true
alias Hypatia.MergeOrchestration.Dispatcher

defp ctx(overrides) do
Map.merge(
%{
pr: %{repo: "hyperpolymath/panll", number: 412},
change_class: :chore,
change_level: :object,
pool: :p2,
attestations: [%{bot: "ci", verdict: :approve, confidence: 0.99}]
},
overrides
)
end

test "arm_auto maps onto the existing dispatch vocabulary: auto_execute / eliminate" do
{_d, m, _l} = Dispatcher.dispatch(ctx(%{}))
assert m["strategy"] == "auto_execute"
assert m["tier"] == "eliminate"
assert m["merge"]["method"] == "squash"
assert m["repo"] == "hyperpolymath/panll"
end

test "a veto -> report_only / control, and the veto rides along in the manifest" do
{_d, m, _l} = Dispatcher.dispatch(ctx(%{attestations: [%{bot: "panicbot", verdict: :veto, rationale: "license/SPDX"}]}))
assert m["strategy"] == "report_only"
assert m["tier"] == "control"
assert [%{bot: "panicbot"}] = m["merge"]["vetoes"]
end

test "P1 clamp -> review / substitute" do
{_d, m, _l} = Dispatcher.dispatch(ctx(%{pool: :p1}))
assert m["strategy"] == "review"
assert m["tier"] == "substitute"
end

test "a lease is minted for the PR territory, with a TTL (LE1)" do
{_d, _m, l} = Dispatcher.dispatch(ctx(%{branch: "dependabot/cargo/x", paths: ["Cargo.lock"]}))
assert l["status"] == "held"
assert l["repo"] == "hyperpolymath/panll"
assert l["territory"]["branch"] == "dependabot/cargo/x"
assert l["territory"]["is_meta"] == false
assert l["expires_at"] > l["acquired_at"]
end

test "a meta change -> control, and the lease marks is_meta and stays owner-unauthorised (LE2)" do
{_d, m, l} = Dispatcher.dispatch(ctx(%{change_level: :meta}))
assert m["tier"] == "control"
assert l["territory"]["is_meta"] == true
assert l["owner_authorized"] == false
end
end
Loading