# Phase 11 — Generic Integration Middleware Platform

## Overview

Phase 11 closes the **architectural gap** between a purpose-built TMS API and a
genuine **Integration Middleware Platform**. Phases 0–10 built an excellent runtime
engine — correct layers, fast ETS routing, DAG execution, adapter registry, flow
builder. What remains is the **surface coupling**: message types are hardcoded in
controllers, schema validation is never called, async routing is unwired, and tenant
isolation has a silent global fallback.

Phase 11 addresses each gap identified in `docs/phases/gaps_in_current_architecture_before_phase-11.md`
as a discrete sub-phase. Each sub-phase is independently deployable and does not
break existing API callers.

```
Phase 11.1  Universal Gateway         — message_type from request, zero-code new flows
Phase 11.2  Schema Validation         — wire schema_registry into the pipeline edge
Phase 11.3  Sync / Async Routing      — route_rules flag + async_jobs pipeline branch
Phase 11.4  Mandatory Tenant Routing  — remove global fallback, enforce tenant isolation
Phase 11.5  Message Versioning        — message_type:version format, adapter declarations
Phase 11.6  Saga / Compensation       — DagExecutor compensating transaction rollback
Phase 11.7  Outbound Webhook Routing  — inbound callback routing for async push models
```

After Phase 11, adding a new integration (e.g. `customer.kyc`) requires:
- Zero code change
- Insert a `route_rules` row and optionally a `schema_registry` row
- The new route is live on next `RouteTable.reload/0` (or immediately via admin UI)

---

## What Changes From Phase 10

| Dimension | Phase 10 | Phase 11 |
|---|---|---|
| Adding new message flow | Write controller + router line + deploy | Insert DB row → live immediately |
| Schema validation | Table exists, never called | Every inbound message validated at edge |
| Async processing | FileController only | Any route can declare `processing_mode: async` |
| Tenant isolation | Falls back silently to global table | Hard-enforced; global fallback removed in production |
| Message versioning | `"transaction.payment"` only | `"transaction.payment:v2"` — adapters declare versions |
| Multi-step failure handling | Pipeline halts, partial state left | Compensating actions rolled back automatically |
| Downstream callbacks | Not supported | Webhook routes accepted and dispatched |

---

## Implementation Status

| Sub-phase | Status | Notes |
|-----------|--------|-------|
| **11.1** Universal Gateway | ✅ **COMPLETE** | `MessageController` + `/messages/:type` + `/route` (X-Message-Type header) |
| **11.2** Schema Validation at Edge | ✅ **COMPLETE** | `SchemaValidator` plug in pipeline; `MwKernel.Error.unprocessable/1` added |
| **11.3** Sync / Async Routing | ✅ **COMPLETE** | `processing_mode` column, `AsyncDispatcher`, `Task.Supervisor` wired |
| **11.4** Mandatory Tenant Routing | ✅ **COMPLETE** | `tenant_strict_mode` flag; `prod.exs` sets `true`; new `:tenant_not_provisioned` error |
| **11.5** Message Versioning | ✅ **COMPLETE** | `parse_version/1` in `Message`; composite ETS key `{base, version}`; v2→v1 fallback chain; `message_version` column migration |
| **11.6** Saga / Compensating Transactions | ✅ **COMPLETE** | `MwKernel.Compensatable` behaviour; `DagRoute.compensation_map`; `DagExecutor` reverse-order rollback via `maybe_compensate/3` |
| **11.7** Outbound Webhook Routing | ✅ **COMPLETE** | `WebhookController` + `/api/v1/webhooks/:source_name` (unauthenticated, 11.7.0) |

---

## Sub-Phase Detail

---

### 🔲 Phase 11.1 — Universal Gateway

**Gap being closed:** Message type is hardcoded in every controller. Adding any new
flow requires a code deploy.

**Before (current state):**
```
POST /api/v1/transactions   → TransactionController → "transaction.payment" (hardcoded)
GET  /api/v1/accounts/:id   → AccountController     → "account.balance"    (hardcoded)
```

**After (target state):**
```
POST /api/v1/messages/transaction.payment  → MessageController (generic)
POST /api/v1/messages/account.balance      → MessageController (generic)
POST /api/v1/messages/customer.kyc         → MessageController (generic) ← zero code change
```

The existing domain controllers (`TransactionController`, `AccountController`) are
**kept intact** for backward compatibility. The generic endpoint is additive.

#### Files to Create

**`apps/gateway_api/lib/gateway_api_web/controllers/message_controller.ex`**
```elixir
defmodule GatewayApiWeb.MessageController do
  @moduledoc """
  Universal inbound gateway. Accepts any message type via URL or header.

  Routes:
    POST /api/v1/messages/:message_type
    POST /api/v1/route          (X-Message-Type header)
  """

  use GatewayApiWeb, :controller

  alias MwKernel.{Context, Message}
  alias MwRouter.Pipeline
  import GatewayApiWeb.ControllerHelpers, only: [render_pipeline_result: 2]

  # POST /api/v1/messages/:message_type
  def dispatch(conn, %{"message_type" => raw_type} = params) do
    message_type = normalize_type(raw_type)
    payload = Map.drop(params, ["message_type"])
    run(conn, message_type, payload)
  end

  # POST /api/v1/route (X-Message-Type header)
  def route(conn, params) do
    case get_req_header(conn, "x-message-type") do
      [raw_type | _] ->
        run(conn, normalize_type(raw_type), params)
      [] ->
        conn
        |> put_status(:bad_request)
        |> json(%{error: "X-Message-Type header is required"})
    end
  end

  defp run(conn, message_type, payload) do
    ctx   = Map.get(conn.assigns, :mw_context, Context.new())
    ikey  = get_req_header(conn, "idempotency-key") |> List.first()
    msg   = Message.new(String.to_atom(message_type), payload, :gateway_api)
    result = Pipeline.run(%{ctx | request: msg, idempotency_key: ikey})
    render_pipeline_result(conn, result)
  end

  # "transaction.payment" or "transaction/payment" → normalised dot string
  defp normalize_type(raw), do: String.replace(raw, "/", ".")
end
```

#### Files to Modify

**`apps/gateway_api/lib/gateway_api_web/router.ex`** — add generic scope:
```elixir
scope "/api/v1", GatewayApiWeb do
  pipe_through [:api, :authenticated]

  # Generic universal gateway (Phase 11.1)
  post "/messages/:message_type", MessageController, :dispatch
  post "/route",                  MessageController, :route

  # Legacy domain-specific routes — kept for backward compatibility
  post "/transactions",          TransactionController, :create
  get  "/transactions/:id",      TransactionController, :show
  get  "/accounts/:id/balance",  AccountController,    :balance
  post "/files/upload",          FileController,       :upload
  get  "/jobs/:id",              JobController,        :show
end
```

#### Verification
```bash
# New generic endpoint — no code change required for new types
curl -X POST /api/v1/messages/transaction.payment \
  -H "Idempotency-Key: test-001" -d '{"amount": 100}'

# Header-based routing
curl -X POST /api/v1/route \
  -H "X-Message-Type: account.balance" -d '{"account_id": "ACC-1"}'

# New type — only requires a route_rules row
curl -X POST /api/v1/messages/customer.kyc -d '{"customer_id": "C-1"}'
```

**Deliverable:** Any `message_type` can be dispatched without a code change.

---

### 🔲 Phase 11.2 — Schema Validation at Edge

**Gap being closed:** The `schema_registry` table exists and has an Ecto schema but
is never queried. Malformed payloads reach adapters and produce silent failures.

**Before (current state):**
```
Inbound request → Pipeline.run → RouteTable → Adapter
                   (no validation)
```

**After:**
```
Inbound request → SchemaValidator.check → Pipeline.run → RouteTable → Adapter
                  (validates against schema_registry if schema exists;
                   passes through if no schema registered — backward compatible)
```

#### New Module

**`apps/mw_router/lib/mw_router/schema_validator.ex`**
```elixir
defmodule MwRouter.SchemaValidator do
  @moduledoc """
  Validates inbound message payload against the schema_registry.

  - If no schema is registered for this message_type + version: passes through (no-op)
  - If schema exists but payload is invalid: halts with 422 Unprocessable Entity
  - JSON Schema validation via ExJsonSchema
  """

  import Ecto.Query
  alias InfraRepo.{Repo, Schemas.SchemaRegistry}
  alias MwKernel.Context

  @spec check(Context.t()) :: Context.t()
  def check(%Context{halted: true} = ctx), do: ctx

  def check(%Context{request: %{type: type, payload: payload}} = ctx) do
    mt = to_string(type)
    {base_type, version} = parse_version(mt)

    case lookup_schema(base_type, version) do
      nil    -> ctx  # No schema registered — pass through
      schema -> validate(ctx, schema, payload)
    end
  end

  def check(ctx), do: ctx

  defp lookup_schema(message_type, version) do
    Repo.one(
      from s in SchemaRegistry,
        where: s.message_type == ^message_type
          and  s.version == ^version
          and  s.active == true,
        limit: 1
    )
  rescue
    _ -> nil
  end

  defp validate(ctx, %SchemaRegistry{json_schema: raw}, payload) do
    with {:ok, schema_map} <- Jason.decode(raw),
         {:ok, resolved}   <- ExJsonSchema.Schema.resolve(schema_map),
         :ok               <- ExJsonSchema.Validator.validate(resolved, payload) do
      ctx
    else
      {:error, errors} ->
        Context.halt(ctx, MwKernel.Error.unprocessable(format_errors(errors)))
    end
  end

  defp parse_version(mt) do
    case String.split(mt, ":") do
      [base, ver] -> {base, ver}
      [base]      -> {base, "v1"}
    end
  end

  defp format_errors(errors) when is_list(errors),
    do: "Schema validation failed: " <> Enum.join(errors, "; ")
  defp format_errors(errors), do: "Schema validation failed: #{inspect(errors)}"
end
```

#### Files to Modify

**`apps/mw_router/lib/mw_router/pipeline.ex`** — insert after `RateLimiter.check`:
```elixir
def run(%Context{} = ctx) do
  ctx
  |> RateLimiter.check()
  |> SchemaValidator.check()     # ← Phase 11.2: validate before routing
  |> resolve_route()
  |> CircuitBreaker.check()
  |> IdempotencyPlug.check()
  |> do_dispatch()
end
```

**`apps/mw_router/mix.exs`** — add `ex_json_schema` dependency:
```elixir
{:ex_json_schema, "~> 0.10"}
```

**Deliverable:** Registering a JSON Schema for `transaction.payment:v1` in the admin
UI causes invalid payloads to be rejected at the edge with a 422 before reaching any adapter.

---

### 🔲 Phase 11.3 — Sync / Async Routing

**Gap being closed:** Every route is synchronous — the caller blocks until the adapter
responds. High-volume flows (payment settlement, batch file processing) should return
a `job_id` immediately and process asynchronously.

**Before:**
```
POST /api/v1/messages/payment.settle
→ blocks caller for up to 30s
→ returns completed response
```

**After:**
```
POST /api/v1/messages/payment.settle
  route_rules.processing_mode = "async"
→ returns 202 Accepted + {"job_id": "JOB-abc123"}
→ pipeline runs in background Task
→ result written to async_jobs table
→ caller polls GET /api/v1/jobs/JOB-abc123
```

#### DB Migration

**`apps/infra_repo/priv/repo/migrations/20260501000004_add_processing_mode_to_route_rules.exs`**
```elixir
defmodule InfraRepo.Repo.Migrations.AddProcessingModeToRouteRules do
  use Ecto.Migration

  def change do
    alter table(:route_rules) do
      add :processing_mode, :string, null: false, default: "sync"
      # Values: "sync" | "async"
    end
  end
end
```

#### Schema + Route Table Changes

**`InfraRepo.Schemas.RouteRule`** — add field:
```elixir
field :processing_mode, :string, default: "sync"
```

**`MwRouter.RouteTable`** — include in ETS entry:
```elixir
rule = %{
  adapter:          adapter_module,
  active:           row.active,
  timeout_ms:       row.timeout_ms,
  priority:         row.priority,
  type:             String.to_atom(row.route_type || "single"),
  processing_mode:  String.to_atom(row.processing_mode || "sync")  # ← new
}
```

#### Pipeline Changes

**`apps/mw_router/lib/mw_router/pipeline.ex`** — branch on processing_mode:
```elixir
defp do_dispatch(%Context{route_spec: %{processing_mode: :async}} = ctx) do
  job_id = AsyncDispatcher.enqueue(ctx)
  %{ctx | response: MwKernel.Message.new(:async_accepted, %{job_id: job_id}, :pipeline)}
end

defp do_dispatch(%Context{route_spec: %{type: :dag}} = ctx),       do: DagExecutor.dispatch(ctx)
defp do_dispatch(%Context{route_spec: %{type: :composite}} = ctx), do: FanoutDispatcher.dispatch(ctx)
defp do_dispatch(ctx),                                              do: Dispatcher.dispatch(ctx)
```

#### New Module

**`apps/mw_router/lib/mw_router/async_dispatcher.ex`** — enqueues work:
```elixir
defmodule MwRouter.AsyncDispatcher do
  @moduledoc "Enqueues a context for background processing. Returns job_id immediately."

  alias InfraRepo.{Repo, Schemas.AsyncJob}

  @spec enqueue(MwKernel.Context.t()) :: String.t()
  def enqueue(ctx) do
    {:ok, job} = %AsyncJob{}
      |> AsyncJob.changeset(%{
        type:   to_string(ctx.request.type),
        status: "pending",
        payload: ctx.request.payload
      })
      |> Repo.insert()

    Task.Supervisor.start_child(MwRouter.TaskSupervisor, fn ->
      run_async(ctx, job.id)
    end)

    to_string(job.id)
  end

  defp run_async(ctx, job_id) do
    result = ctx
      |> MwRouter.CircuitBreaker.check()
      |> MwRouter.IdempotencyPlug.check()
      |> do_dispatch()

    status = if result.halted, do: "failed", else: "completed"
    Repo.get(AsyncJob, job_id)
    |> AsyncJob.changeset(%{
      status:       status,
      completed_at: DateTime.utc_now(),
      result_summary: %{
        halted: result.halted,
        response: snapshot(result.response)
      }
    })
    |> Repo.update()
  end

  defp do_dispatch(%{route_spec: %{type: :dag}} = ctx),       do: MwRouter.DagExecutor.dispatch(ctx)
  defp do_dispatch(%{route_spec: %{type: :composite}} = ctx), do: MwRouter.FanoutDispatcher.dispatch(ctx)
  defp do_dispatch(ctx),                                       do: MwRouter.Dispatcher.dispatch(ctx)

  defp snapshot(nil), do: nil
  defp snapshot(%{payload: p}), do: p
end
```

**Deliverable:** Setting `processing_mode = "async"` on any `route_rules` row causes
the caller to receive `202 + job_id` immediately. Background task processes and updates
`async_jobs`. Caller polls `/api/v1/jobs/:id`.

---

### 🔲 Phase 11.4 — Mandatory Tenant Routing

**Gap being closed:** When a tenant ETS table doesn't exist, the pipeline silently
falls back to the global table. In production, this masks mis-provisioned tenants and
risks cross-tenant data leakage in misconfiguration scenarios.

**Before:**
```elixir
# route_table.ex — lookup/2
true ->
  lookup(message_type)  # silent global fallback — risky in production
```

**After:**
- `production` environment: global fallback is disabled; missing tenant table = 404
- `dev`/`test` environment: global fallback kept (existing behaviour unchanged)
- New `RouteTable.init_tenant_table/1` called automatically on tenant creation

#### Files to Modify

**`apps/mw_router/lib/mw_router/route_table.ex`** — environment-aware fallback:
```elixir
def lookup(tenant_id, message_type) when is_binary(tenant_id) and is_atom(message_type) do
  key = Atom.to_string(message_type)
  tbl = tenant_table_name(tenant_id)

  cond do
    table_exists?(tbl) ->
      case :ets.lookup(tbl, key) do
        [{^key, %{active: true}  = entry}] -> {:ok, normalize_entry(entry)}
        [{^key, %{active: false}}]         -> {:error, :not_found}
        []                                 -> {:error, :no_route_for_tenant}
      end

    tenant_fallback_allowed?() ->
      lookup(message_type)  # dev/test only

    true ->
      {:error, :tenant_not_provisioned}
  end
end

defp tenant_fallback_allowed? do
  Application.get_env(:mw_router, :tenant_strict_mode, false) == false
end
```

**`config/config.exs`:**
```elixir
config :mw_router, :tenant_strict_mode, false   # dev/test: global fallback allowed
```

**`config/prod.exs`:**
```elixir
config :mw_router, :tenant_strict_mode, true    # production: hard enforce tenant isolation
```

**`apps/mw_router/lib/mw_router/pipeline.ex`** — handle new error:
```elixir
{:error, :tenant_not_provisioned} ->
  Context.halt(ctx, MwKernel.Error.not_found("Tenant routing table not provisioned: #{tenant_id}"))
```

**Deliverable:** In production config, a request with an unknown `tenant_id` returns 404
instead of silently routing via the global table.

---

### ✅ Phase 11.5 — Message Versioning

**Gap being closed:** Payload contracts cannot evolve without breaking live adapters.
`"transaction.payment"` and a changed payload structure are indistinguishable.

**Design:**
```
message_type format:  "transaction.payment"        → version defaults to "v1"
                      "transaction.payment:v2"     → explicit version v2
```

Adapters declare which versions they handle. The route table matches on
`{message_type, version}` with a version fallback chain: `v2 → v1 → error`.

#### DB Migration

**`apps/infra_repo/priv/repo/migrations/20260501000005_add_version_to_route_rules.exs`**
```elixir
def change do
  alter table(:route_rules) do
    add :message_version, :string, null: false, default: "v1"
  end
  # Drop the existing unique index on message_type alone
  drop unique_index(:route_rules, [:message_type])
  # New unique index on (message_type, message_version)
  create unique_index(:route_rules, [:message_type, :message_version])
end
```

#### RouteTable Changes

ETS key changes from `message_type_string` to `{message_type_string, version_string}`:

```elixir
# lookup/1 (global, backward compat) — tries exact version then "v1"
def lookup(message_type, version \\ "v1") when is_atom(message_type) do
  key = {Atom.to_string(message_type), version}
  case :ets.lookup(@table, key) do
    [{^key, %{active: true} = entry}] -> {:ok, normalize_entry(entry)}
    _                                  -> {:error, :not_found}
  end
end
```

#### Pipeline Changes

Parse version from message type string before lookup:
```elixir
defp resolve_route(%Context{request: %{type: type}} = ctx) do
  {base_type, version} = MwKernel.Message.parse_version(to_string(type))
  # ... pass version to RouteTable.lookup
end
```

**Deliverable:** `POST /api/v1/messages/transaction.payment:v2` routes to the v2
adapter while `transaction.payment` (no suffix) routes to the v1 adapter. Both can
coexist with different adapter configs.

---

### ✅ Phase 11.6 — Saga / Compensating Transactions

**Gap being closed:** In multi-step DAG flows, if step N fails after steps 1..N-1
succeeded, the pipeline halts with partial state. For payments this is a compliance
risk — a successful payment + failed audit log leaves an irreconcilable state.

**Design:**

Each adapter in a DAG flow can declare a `compensation_module` — an adapter module
called when rollback is needed. `DagExecutor` tracks which nodes completed and,
on downstream failure, calls compensation in reverse order.

#### `DagRoute` Changes

```elixir
# mw_kernel/lib/mw_kernel/dag_route.ex
defstruct [
  :nodes,
  :connections,
  compensation_map: %{}  # %{"node_id" => CompensatingAdapterModule}
]
```

#### `DagExecutor` Rollback

```elixir
# On node failure in DagExecutor:
defp maybe_compensate(ctx, failed_node_id, completed_nodes, dag_route) do
  nodes_to_compensate = Enum.filter(completed_nodes, fn node_id ->
    Map.has_key?(dag_route.compensation_map, node_id)
  end)
  |> Enum.reverse()  # reverse order: last completed = first compensated

  Enum.each(nodes_to_compensate, fn node_id ->
    comp_module = dag_route.compensation_map[node_id]
    case comp_module.compensate(ctx) do
      :ok -> :ok
      {:error, reason} ->
        Logger.error("[DagExecutor] Compensation failed for node #{node_id}: #{inspect(reason)}")
    end
  end)
end
```

#### New Behaviour

**`apps/mw_kernel/lib/mw_kernel/behaviours/compensatable.ex`**
```elixir
defmodule MwKernel.Compensatable do
  @moduledoc "Optional behaviour for adapters that support compensating transactions."

  @callback compensate(MwKernel.Context.t()) :: :ok | {:error, term()}
end
```

**Deliverable:** If `AuditLogAdapter` fails after `PaymentAdapter` succeeded,
`DagExecutor` automatically calls `PaymentAdapter.Compensation.compensate/1` (reverse
the payment) before returning the error to the caller.

---

### 🔲 Phase 11.7 — Outbound Webhook Routing

**Gap being closed:** Downstream systems (fraud vendors, payment networks, KYC
providers) are event-driven — they POST results back asynchronously. There is
currently no way to receive and route these callbacks.

**Design:**
```
POST /api/v1/webhooks/:source_name
  e.g. POST /api/v1/webhooks/fraud_guard
       POST /api/v1/webhooks/payment_network_callback

→ WebhookController normalises the inbound payload
→ Looks up route_rules where route_type = "webhook" and source = source_name
→ Dispatches via standard Pipeline
```

#### New Route Type

Add `"webhook"` as a valid `route_type` in `route_rules`. Webhook routes have an
additional `webhook_source` string column.

**`apps/infra_repo/priv/repo/migrations/20260501000006_add_webhook_to_route_rules.exs`**
```elixir
def change do
  alter table(:route_rules) do
    add :webhook_source, :string  # null unless route_type = "webhook"
  end
end
```

#### New Controller

**`apps/gateway_api/lib/gateway_api_web/controllers/webhook_controller.ex`**
```elixir
defmodule GatewayApiWeb.WebhookController do
  @moduledoc "Receives inbound callbacks from downstream systems."

  use GatewayApiWeb, :controller

  alias MwKernel.{Context, Message}
  alias MwRouter.Pipeline
  import GatewayApiWeb.ControllerHelpers, only: [render_pipeline_result: 2]

  def receive(conn, %{"source_name" => source} = params) do
    payload = Map.drop(params, ["source_name"])
    message_type = String.to_atom("webhook.#{source}")
    msg = Message.new(message_type, payload, :webhook)
    ctx = Context.new()
    result = Pipeline.run(%{ctx | request: msg})
    render_pipeline_result(conn, result)
  end
end
```

**`apps/gateway_api/lib/gateway_api_web/router.ex`** — add unauthenticated webhook scope:
```elixir
# Webhook callbacks — authenticated via HMAC signature (Phase 11.7 hardening)
scope "/api/v1", GatewayApiWeb do
  pipe_through [:api]
  post "/webhooks/:source_name", WebhookController, :receive
end
```

**Deliverable:** A fraud vendor can POST `POST /api/v1/webhooks/fraud_guard` and the
callback is routed to a configured adapter (e.g. `AuditLogAdapter`) via the standard
pipeline. Webhook source routing is fully configuration-driven.

---

## Dependency Map

```
11.1 Universal Gateway    — prerequisite for all other sub-phases (generic entry point)
11.2 Schema Validation    — depends on 11.1 (validates before routing)
11.3 Sync/Async Routing   — depends on 11.1 (routing decision happens at dispatch)
11.4 Tenant Routing       — independent (pipeline-only change)
11.5 Message Versioning   — depends on 11.1 (version parsed from message_type string)
11.6 Saga                 — independent (DagExecutor-only change)
11.7 Webhooks             — independent (new endpoint, uses existing pipeline)
```

Recommended implementation order: **11.1 → 11.4 → 11.2 → 11.3 → 11.7 → 11.5 → 11.6**

Rationale:
- 11.1 first — the keystone change; every other sub-phase benefits from the generic entry point
- 11.4 next — trivial config change; prevents a production isolation risk before you go further
- 11.2 + 11.3 — pipeline additions that work cleanly once 11.1 is in place
- 11.7 — independent, adds a new surface without touching the pipeline
- 11.5 — versioning is a larger DB + ETS key change; do after the pipeline is stable
- 11.6 — most complex (compensation state machine); do last

---

## File Map

### New Files

```
apps/gateway_api/lib/gateway_api_web/controllers/
  message_controller.ex               ← Phase 11.1  universal gateway
  webhook_controller.ex               ← Phase 11.7  inbound webhook receiver

apps/mw_router/lib/mw_router/
  schema_validator.ex                 ← Phase 11.2  JSON Schema validation plug
  async_dispatcher.ex                 ← Phase 11.3  enqueue + background Task

apps/mw_kernel/lib/mw_kernel/behaviours/
  compensatable.ex                    ← Phase 11.6  @callback compensate/1

apps/infra_repo/priv/repo/migrations/
  20260501000004_add_processing_mode_to_route_rules.exs    ← Phase 11.3
  20260501000005_add_version_to_route_rules.exs            ← Phase 11.5
  20260501000006_add_webhook_to_route_rules.exs            ← Phase 11.7
```

### Modified Files

```
apps/gateway_api/lib/gateway_api_web/router.ex
  ← Phase 11.1: generic /messages/:type + /route scopes
  ← Phase 11.7: /webhooks/:source_name scope

apps/mw_router/lib/mw_router/pipeline.ex
  ← Phase 11.2: SchemaValidator.check/1 after RateLimiter
  ← Phase 11.3: async branch in do_dispatch
  ← Phase 11.4: :tenant_not_provisioned error handling

apps/mw_router/lib/mw_router/route_table.ex
  ← Phase 11.4: tenant_fallback_allowed?/0
  ← Phase 11.5: {message_type, version} composite ETS key

apps/mw_kernel/lib/mw_kernel/dag_route.ex
  ← Phase 11.6: compensation_map field

apps/mw_router/lib/mw_router/dag_executor.ex
  ← Phase 11.6: maybe_compensate/4 on node failure

apps/infra_repo/lib/infra_repo/schemas/route_rule.ex
  ← Phase 11.3: processing_mode field
  ← Phase 11.5: message_version field
  ← Phase 11.7: webhook_source field

config/config.exs
  ← Phase 11.4: :tenant_strict_mode false

config/prod.exs (create if absent)
  ← Phase 11.4: :tenant_strict_mode true

apps/mw_router/mix.exs
  ← Phase 11.2: {:ex_json_schema, "~> 0.10"}
```

---

## Dependencies

| Package | Version | Reason | Status |
|---|---|---|---|
| `ex_json_schema` | `~> 0.10` | JSON Schema validation (Phase 11.2) | 🔲 to add |
| `jason` | already present | Schema decode, webhook payload | ✅ |
| `finch` | already present | Adapter HTTP calls | ✅ |
| All other deps | already present | No new dependencies beyond ex_json_schema | ✅ |

---

## Definition of Done

### Phase 11.1
- [ ] `POST /api/v1/messages/transaction.payment` routes correctly without any controller change
- [ ] `POST /api/v1/messages/customer.kyc` returns 404 (no route) when no `route_rules` row exists
- [ ] `POST /api/v1/route` with `X-Message-Type` header routes correctly
- [ ] Existing `/api/v1/transactions` and `/api/v1/accounts/:id/balance` still work unchanged

### Phase 11.2
- [ ] Registering a JSON Schema for `transaction.payment:v1` causes invalid payloads to return 422
- [ ] Valid payloads pass through unchanged
- [ ] Message types with no registered schema pass through (no-op)
- [ ] Schema errors appear in audit_events with `status: "error"`

### Phase 11.3
- [ ] Setting `processing_mode = "async"` on a `route_rules` row returns `202 + job_id`
- [ ] Background task runs and updates `async_jobs.status` to "completed" or "failed"
- [ ] `GET /api/v1/jobs/:id` returns job status and result_summary
- [ ] Sync routes unaffected

### Phase 11.4
- [ ] With `tenant_strict_mode: false` (dev), missing tenant table falls back to global (unchanged)
- [ ] With `tenant_strict_mode: true` (prod), missing tenant table returns 404 with "not provisioned"
- [ ] `prod.exs` sets `tenant_strict_mode: true`

### Phase 11.5
- [ ] `POST /api/v1/messages/transaction.payment:v2` routes to v2 route_rules row
- [ ] `POST /api/v1/messages/transaction.payment` (no version) routes to v1 row
- [ ] Both v1 and v2 rows can coexist in route_rules with different adapters
- [ ] No regression on existing message types (all treated as v1)

### Phase 11.6
- [ ] DAG flow with `compensation_map` set: on node failure, completed nodes are compensated in reverse order
- [ ] Compensation failure is logged but does not mask the original error
- [ ] DAG flows without `compensation_map` are unaffected

### Phase 11.7
- [ ] `POST /api/v1/webhooks/fraud_guard` dispatches via pipeline to configured adapter
- [ ] Unknown webhook source returns 404
- [ ] Webhook payloads appear in audit_events

---

## Open Questions

| # | Question | Owner | Status |
|---|---|---|---|
| 1 | Should the generic `/api/v1/messages/:type` endpoint require `Idempotency-Key` for all POST requests or only for idempotency-enabled message types? | Product | Pending — proposal: make it optional at the generic endpoint, required if `route_rules.idempotency_required = true` |
| 2 | For Phase 11.3 async routing, should the caller be able to override `processing_mode` per-request via header `X-Processing-Mode: async`? | Product | Pending — would allow testing async behaviour without a DB change |
| 3 | Phase 11.5 versioning: should the version fallback chain be `v2 → v1 → error` or `v2 → error` (strict)? | Engineering | Pending — fallback is safer for migrations; strict is cleaner for contracts |
| 4 | Phase 11.6 saga: should compensation be attempted even if previous compensation steps failed, or stop on first compensation failure? | Engineering | Pending — proposal: best-effort (attempt all, log failures, never mask original error) |
| 5 | Phase 11.7 webhook authentication: HMAC-SHA256 signature verification or mTLS? | Security | Pending — HMAC simpler to implement; mTLS better for high-security payment networks. Phase 11.7.0 = no auth (internal only); Phase 11.7.1 = HMAC hardening |
| 6 | Should the admin UI (gateway_web) show the `processing_mode` field in the Route Config editor? | Product | Pending — yes, add as a dropdown alongside the existing fields |
| 7 | For Phase 11.4 strict tenant mode: should `init_tenant_table/1` be called automatically when a `tenants` row is created, or must operators trigger it manually? | Engineering | Pending — proposal: hook on `Tenant` schema insert via Ecto callbacks or a Tenant provisioning GenServer |
