Service admin interfaces
Copy MarkdownIncant'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
endThis 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
endResolution order:
- explicit
resource MyResourceModuleentries; - conventional override modules for
expose Schema, such asBilling.Admin.Resources.Invoice; - 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
endThat 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
endLong-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.IncantRegistryIncant 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.etfThe 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/runsRequest 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.