Distributed Services

Copy Markdown View Source

Incant's distributed model keeps admin behavior with the service that owns the data. A central host discovers portable contracts, renders them, and sends semantic operations back to the service over SafeRPC.

Billing service                    Central Incant host
---------------                    -------------------
Billing.Admin                      RegistryServer
Billing.Repo             ◀──────▶  Service.Client
Billing.Admin.Policy      SafeRPC  LiveView adapter
queries and actions                semantic HTTP API

The central host does not load Billing.Repo, schema modules, callbacks, or policy code.

Service-owned surface

defmodule Billing.Admin do
  use Incant.Admin,
    service: :billing,
    version: "1",
    repo: Billing.Repo,
    policy: Billing.Admin.Policy,
    rpc: true

  expose Billing.Invoices.Invoice
  expose Billing.Customers.Customer
  dashboard Billing.Admin.Dashboards.Operations
end

expose/2 is convention-first. Given Billing.Invoices.Invoice, Incant resolves surfaces in this order:

  1. an explicitly registered resource module;
  2. a conventional override such as Billing.Admin.Resources.Invoice;
  3. an inferred Ecto resource from the schema and admin repo.

Local metadata and portable contracts

Local metadata can contain executable BEAM terms:

Incant.metadata(Billing.Admin)

That result may include modules, repos, schemas, callbacks, and function captures. It stays inside the service VM.

A public description is transport-safe:

Incant.Admin.describe(Billing.Admin)

The description normalizes resources, dashboards, datasets, controls, fields, actions, and presentation metadata into an Incant.Admin.Contract. IDs are stable strings suitable for URLs and RPC payloads.

SafeRPC operations

With rpc: true, the admin module exposes explicit service operations:

{Billing.Admin, :describe}
{Billing.Admin, :index}
{Billing.Admin, :read}
{Billing.Admin, :run_action}

Host the module with SafeRPC:

defmodule Billing.AdminServer do
  use SafeRPC.Adapter.Server, service: Billing.Admin
end
children = [
  {Billing.AdminServer,
   socket: "/run/billing/admin.sock",
   socket_mode: 0o660}
]

Only declared SafeRPC operations are callable. Incant does not expose arbitrary remote MFA.

Registry discovery

A binding file maps service keys to local sockets and candidate modules:

%{
  "billing" => %{
    "socket" => "/run/billing/admin.sock",
    "modules" => ["Elixir.Billing.Admin"]
  }
}

Load once:

{:ok, registry} = Incant.Service.Registry.load()

for %Incant.Service.Entry{client: client, contract: contract} <- registry.entries do
  {client, contract.service, contract.surfaces}
end

Or supervise a refreshable registry:

children = [
  {Incant.Service.RegistryServer, name: MyApp.IncantRegistry}
]

entries = Incant.Service.RegistryServer.list_entries(MyApp.IncantRegistry)
{:ok, registry} = Incant.Service.RegistryServer.refresh(MyApp.IncantRegistry)

The default loader reads the binding path from HOSTKIT_RPC_BINDINGS. Incant.Service.Registry.load/1 accepts a different environment variable when deployment naming differs.

Render local and remote surfaces

The router macro uses the same LiveView session boundary for local and remote admins:

incant "/admin", MyApp.Admin
incant "/admin", registry: MyApp.IncantRegistry

Incant.Live.Admin talks to an Incant.Session; it does not branch on whether a surface is local or remote.

Service sessions

Wrap a discovered entry in a session for rendering or lower-level application code:

session = Incant.Service.Session.new(entry, context: %{actor: actor})

Incant.Service.Session.list_surfaces(session, kind: :resource)
Incant.Service.Session.index(session, "invoice", %{page: 1})
Incant.Service.Session.read(session, "invoice", "123")
Incant.Service.Session.run_action(session, "invoice", "refund", %{id: "123"})

The service session translates those calls into typed request structs and SafeRPC operations.

Lower-level callers can construct a client explicitly:

client = Incant.Service.client(binding, module: Billing.Admin)

Incant.Service.index(client, %Incant.Service.Index{
  surface_id: "invoice",
  params: %{page: 1}
})

Prefer sessions in UI code so rendering stays independent of transport request structs.

Query execution

Portable resource queries describe table state as data. The service receives search, filters, sorting, pagination, variables, and context, then executes against its local repo or callback.

For remote Ecto resources:

  • the schema and repo stay service-local;
  • filter values are cast in the service;
  • policy query scoping runs before pagination;
  • rows are redacted and normalized before transport;
  • callbacks and function captures are never serialized.

Action execution

Row, bulk, and page actions use one semantic operation:

%Incant.Service.RunAction{
  surface_id: "invoice",
  action_id: "refund",
  payload: %{
    id: "123",
    selected_ids: nil,
    assigns: %{},
    input: %{}
  }
}

The service resolves the action, authorizes the actor/context, loads local rows when needed, invokes the handler, and converts the result into portable action-result data.

One-time secrets should use Incant.ActionResult.Reveal; the central adapter renders the reveal but the service owns generation and authorization.

Authorization

Keep authorization service-local. A central host may pass actor or tenant context, but each service decides whether to trust and authorize it.

For strong identity boundaries, derive signed or authenticated claims at the service edge rather than trusting arbitrary maps from the browser. The central UI's operator authentication and the service policy solve different problems.

Failure behavior

Registry and transport failures are explicit. A service with a missing socket, incompatible descriptor, invalid contract, or failed describe call is not silently treated as an empty service.

Long-running hosts can refresh the registry after service replacement. Stable service and surface IDs keep routes and navigation consistent across refreshes.