Service admin interfaces

Copy Markdown

Incant's distributed admin model is service-owned.

Each service declares the admin surface it exposes in its own application namespace, using ordinary Incant DSL modules. A central Incant admin can discover, render, and dispatch those surfaces; it should not own the service's resource/action definitions.

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

  expose Billing.Invoices.Invoice
  expose Billing.Customers.Customer

  dashboard Billing.Admin.Dashboards.Operations
  dataset Billing.Admin.Datasets.Revenue
end

This mirrors how Mix tasks live in project namespaces: the framework defines the behaviour and conventions, while applications define their own modules.

expose/2 is convention-first. By default, Incant infers a resource from the Ecto schema and configured repo. If the application defines a conventional resource module, Incant uses it automatically without changing the admin root:

defmodule Billing.Admin.Resources.Invoice do
  use Incant.Resource,
    schema: Billing.Invoices.Invoice

  table do
    column :number, link: true
    column :status, as: :badge
    action :refund, confirm: true
  end
end

Resolution order:

  1. explicit resource MyResourceModule entries;
  2. conventional override modules for expose Schema, such as Billing.Admin.Resources.Invoice;
  3. inferred Ecto resources from Schema.__schema__/1.

Local authoring, portable description

Top-level admin concepts are normalized as surfaces. A surface is a common envelope around kind-specific metadata:

%Incant.Surface{
  kind: :resource,
  id: "invoice",
  module: Billing.Admin.Resources.Invoice,
  title: "Invoices",
  spec: %Incant.Resource.Metadata{}
}

The envelope owns identity for routes, navigation, public contracts, and remote transports. The spec owns resource/dashboard/dataset-specific semantics.

The service-local module remains the source of truth:

Incant.metadata(Billing.Admin)
Incant.Admin.describe(Billing.Admin)

Incant.metadata/1 returns local BEAM metadata, which may include modules, function captures, repos, schemas, and callbacks.

Incant.Admin.describe/1 should return a portable, transport-safe admin contract. That contract is the thing suitable for remote discovery, persistence, audits, UI preloading, and SafeRPC responses.

The important split is:

  • local metadata may contain executable BEAM terms;
  • public descriptions contain only stable data;
  • service-local executors own callbacks, repos, policies, and side effects.

SafeRPC transport

A service opts into SafeRPC by enabling RPC on its ordinary admin module:

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

  expose Billing.Invoices.Invoice
end

That module still implements the local Incant.Service behaviour:

Billing.Admin.describe(context)
Billing.Admin.index("invoice", params, context)
Billing.Admin.read("invoice", id, context)
Billing.Admin.run_action("invoice", "refund", payload, context)

With rpc: true, those same service functions are exposed through SafeRPC as explicit module/function operations:

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

Central Incant admin code should not repeat those operation tuples directly. In a HostKit deployment it should load the runtime registry from the binding file path injected as HOSTKIT_RPC_BINDINGS:

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

for %Incant.Service.Entry{client: client, contract: contract} <- registry.entries do
  # render the contract and dispatch later user actions through the same client
end

Long-running admin applications can supervise a registry server instead:

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

Supervisor.start_link(children, strategy: :one_for_one)

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

A Phoenix app mounts local or service-backed Incant admin through the same router macro:

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

Incant can also run as a standalone app when configured with serve?: true:

config :incant,
  serve?: true,
  registry: [env: "HOSTKIT_RPC_BINDINGS"]

Standalone mode starts Incant.Service.RegistryServer and Incant.Web.Endpoint, then uses the same incant router macro internally. Library/embedded mode remains the default and starts no Incant children.

Build the standalone release with:

MIX_ENV=prod mix release incant

Runtime configuration uses standard Mix release config:

INCANT_SERVE=true
INCANT_HTTP_IP=127.0.0.1
INCANT_HTTP_PORT=4000
INCANT_SECRET_KEY_BASE=...
HOSTKIT_RPC_BINDINGS=/run/example/admin/rpc.etf

The macro owns the private Phoenix LiveView session shape. Incant.Live.Admin consumes a selected Incant.Session through the same protocol used for local admin modules; it does not branch on local vs remote transport.

The registry decodes the ETF binding term safely:

bindings =
  path
  |> File.read!()
  |> :erlang.binary_to_term([:safe])

Then it calls SafeRPC.describe/1, selects modules exposing the Incant service shape, and loads each contract through Incant.Service.describe/1.

For lower-level callers, Incant can also discover service clients directly from decoded bindings:

{:ok, clients} = Incant.Service.discover(bindings)

For a known binding/module pair, 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}})
Incant.Service.read(client, %Incant.Service.Read{surface_id: "invoice", id: "123"})
Incant.Service.run_action(client, %Incant.Service.RunAction{
  surface_id: "invoice",
  action_id: "refund",
  payload: %{id: "123"}
})

UI code should usually wrap registry entries in Incant.Service.Session so rendering does not know about request structs:

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"})

SafeRPC moves request structs and responses. The service-local admin module still owns callbacks, repos, policies, and side effects.

Standalone HTTP API

Standalone Incant also exposes a JSON HTTP API for the same central-admin session boundary under /incant. The HTTP API is intentionally semantic: it resolves a discovered service entry, wraps it in Incant.Service.Session, and calls only the standard Incant session operations. It does not expose arbitrary module/function calls.

Successful responses use the application/vnd.incant.admin+json media type and a typed document shape:

{
  "data": {},
  "links": {},
  "meta": {}
}

Errors use RFC 9457 Problem Details with application/problem+json. Incant currently uses type: "about:blank" plus a stable code extension member until package docs define resolvable problem type URIs:

{
  "type": "about:blank",
  "code": "unknown-service",
  "title": "Unknown service",
  "status": 404,
  "detail": "No Incant service named billing is registered.",
  "instance": "/incant/services/billing"
}

Routes:

GET  /incant
GET  /incant/services
GET  /incant/services/:service
GET  /incant/services/:service/surfaces
GET  /incant/services/:service/surfaces/:surface
GET  /incant/services/:service/surfaces/:surface/rows
POST /incant/services/:service/surfaces/:surface/queries
GET  /incant/services/:service/surfaces/:surface/rows/:id
GET  /incant/services/:service/surfaces/:surface/actions
GET  /incant/services/:service/surfaces/:surface/actions/:action
POST /incant/services/:service/surfaces/:surface/actions/:action/runs

Request bodies use strict JSONCodec-backed contracts. JSON string keys are decoded once at the HTTP boundary into typed request structs such as Incant.Web.API.QueryRequest and Incant.Web.API.ActionRunRequest.

Query request body:

{
  "table": {"page": 1, "page_size": 25},
  "context": {}
}

Action run request body:

{
  "payload": {
    "id": null,
    "selected_ids": null,
    "assigns": {},
    "input": {}
  },
  "context": {}
}

Row actions set payload.id, bulk actions set payload.selected_ids, and page actions set neither. The service-local action callback still decides what the payload means and performs the side effect. Synchronous action runs return 200 OK with an action_run resource in data; future asynchronous action runs should return 202 Accepted with Location and Retry-After.

Admin API responses include Cache-Control: no-store and Vary: Accept. Method mismatches return 405 Method Not Allowed with an Allow header. Unsupported request media types return 415 Unsupported Media Type; unacceptable Accept headers return 406 Not Acceptable.

Design constraints

  • Do not expose arbitrary remote MFA over the wire; only explicitly generated module/function operations are callable.
  • Do not serialize local callbacks or repo/schema modules as the public contract.
  • Keep action/resource identifiers stable and string-safe for URLs and RPC payloads.
  • Keep authorization service-local; the central UI can pass actor/context claims, but the service decides.
  • Treat the public description as inspectable data, not as executable code.
  • Keep semantic UI separate from transport. SafeRPC moves contracts and operation requests; Incant adapters render semantic surfaces.

Direction

The next Incant layer should introduce explicit modules for this boundary:

  • Incant.Admin.describe/1 — local metadata to portable contract.
  • Incant.Admin.Contract — transport-safe admin contract struct/schema.
  • Incant.Admin.Executor — service-local resource/dataset/action execution.
  • use Incant.Admin, rpc: true — SafeRPC exposure for the admin module's standard service functions.
  • Incant.Service.Client — central-side client handle for remote admin surfaces.