Extensions

View Source

An extension is an ordinary OTP application that depends on asobi, added as a dependency of your release, plus one small module telling asobi the few things it cannot discover.

No new packaging concept, no new lifecycle, nothing to learn beyond OTP.

This contract is experimental. The wire freezes, because SDK users vendor by copying source. The manifest does not, until a real second consumer has said what it is missing.

Installing one

%% your_game_app/rebar.config
{deps, [
    {asobi, "~> 0.68"},
    {asobi_quests, {git, "https://github.com/you/asobi_quests.git", {tag, "v1.0.0"}}}
]}.

{relx, [{release, {your_game, "1.0.0"},
         [your_game_app, asobi_quests, asobi, sasl]}]}.

Two lines. Removing an extension is deleting them; its tables survive until deliberately purged, because destroying player progress on a dependency change is the wrong default.

asobi_quests depends on asobi and your app depends on both. asobi never depends on an extension, so the reverse edge is a cycle relx sorts into a build failure and Hex rejects outright.

Validate the set before you boot it:

{project_plugins, [{asobi, {git, "https://github.com/widgrensit/asobi.git", {tag, "v0.83.5"}}}]}.
rebar3 asobi check

Pin the plugin to the same tag as the dependency. Core's reserved names come from the plugin's own copy of asobi, so a skewed pin validates against the wrong reserved set.

This is the gate. asobi validates the same set again at boot, but a boot-time failure is raised from inside Nova's route compilation and surfaces with Nova's crash context rather than a legible asobi error.

The published image ghcr.io/widgrensit/asobi runs a release built from a fixed application set at image build time, so installing a third-party extension means building your own release from the Hex package.

Who calls an extension

CallerPathFor
Game logic, in-match, server-sidegame.quests.progress(player_id, 1)"player killed something, +1"
Game client, over the networkan rpc.call frame - ws.rpc(...) in JS, rpc_call(...) in Godot, realtime:rpc(...) in Defold and LÖVE"give me my reward"
An operator, on the ops planePOST /api/v1/ops/ext/quests/define"add a daily quest"
An HTTP caller, on a declared routeGET /api/v1/quests/board via routes/0a preserved REST surface, or a store's webhook

An extension with only the wire cannot observe gameplay; one with only Lua cannot be triggered by a player action from the client. The third is a different audience, not a third way to reach the same one: rpc/0 is player-scoped, and no player ever holds an operator capability. The fourth is deliberately narrow - see Declaring an HTTP route for when a route is the right answer and when rpc/0 is.

What you declare

-module(asobi_quests_extension).
-behaviour(asobi_extension).
-export([info/0, requires/0, rpc/0, lua/0, sup/0, owns/0, codes/0, ops/0,
         erase_player/1, export_player/1]).

info() -> #{name => quests, extension_version => 1}.

requires() -> [economy].

rpc()  -> #{~"quests.list"  => {asobi_quests_rpc, list,  2},
            ~"quests.claim" => {asobi_quests_rpc, claim, 2}}.

lua()  -> #{~"quests" =>
              #{~"progress" => #{mfa     => {asobi_quests_lua, progress, 2},
                                 args    => [binary, integer],
                                 effects => write,
                                 vms     => [match, world]}}}.

sup()  -> [#{id    => asobi_quests_tracker,
             start => {asobi_quests_tracker, start_link, []}}].

owns() -> #{tables => [~"quests", ~"quest_progress"],
            rpc    => [~"quests"],
            lua    => [~"quests"],
            queues => [~"quests"]}.

codes() -> #{~"quests.already_claimed" =>
               #{status => 409, message => ~"This quest was already claimed."}}.

ops()  -> #{~"define" => #{method => post,
                           mfa    => {asobi_quests_ops, define, 2},
                           class  => config}}.

Discovery looks for a module literally named <app>_extension in the application's own module list (application:get_key(App, modules)). The -behaviour attribute is not what makes it found; the name is. Depending on asobi is not the filter either, or every game embedding asobi would be an extension.

Only info/0 is required. The rest default to nothing.

  • requires/0 - the subsystems and extensions this one calls, by name. See Declaring dependencies.
  • rpc/0 - core cannot guess that quests.claim is {asobi_quests_rpc, claim, 2}. The arity is always 2; see Writing an RPC handler.
  • lua/0 - the game.<ns>.* surface a Lua game calls. See Writing a Lua binding.
  • sup/0 - child specs, if you want asobi supervising them.
  • owns/0 - the closed statement of what this extension claims. See Namespaces.
  • codes/0 - the error codes this extension mints. See Error codes.
  • ops/0 - operator actions, reached on the ops plane rather than by a player. See Writing an operator action.
  • routes/0 - HTTP routes, for preserved REST surfaces and webhooks. See Declaring an HTTP route.
  • erase_player/1 - how to erase one player, when your rows do not cascade. See Deleting a player.
  • info/0 - name is the extension's identity and the root of everything it owns. extension_version is recorded in the registry and printed by rebar3 asobi check; nothing enforces it, and no behaviour changes with it.

Writing an RPC handler

A client calls a declared method over the WebSocket:

{"type": "rpc.call", "cid": "c-1",
 "payload": {"protocol": 1, "method": "quests.claim", "params": {"quest_key": "daily_kills"}}}

and gets back one of:

{"type": "rpc.ok",    "cid": "c-1", "payload": {"result": {"quest_key": "daily_kills", "currency": "gold", "amount": 100}}}
{"type": "rpc.error", "cid": "c-1", "payload": {"error": {"code": "quests.already_claimed", "message": "...", "details": {}}}}

See WebSocket protocol for the frames either side of this one. cid is required here and validated server-side (1-64 printable ASCII bytes), unlike the optional echo the rest of the socket takes: it is the only way a client pairs a reply with the call it made. params and result are always objects, so either can grow a field without breaking a shipped client.

protocol is core's RPC payload version, currently 1, and is unrelated to your extension_version. Any other value answers rpc.unsupported_protocol with details.supported listing what this node speaks. A node reports its own from asobi_rpc:protocol/0.

You rarely build that frame by hand. Every client SDK wraps it, generates the cid and correlates the reply for you:

// asobi-js: resolves with `result`, rejects with an AsobiRpcError
try {
  const { currency, amount } = await ws.rpc("quests.claim", { quest_key: "daily_kills" });
} catch (e) {
  if (e.code === "quests.already_claimed") { /* domain outcome */ }
}
# asobi-godot: the reply arrives on the callable, keyed by the returned cid
realtime.rpc_call("quests.claim", {"quest_key": "daily_kills"}, func(ok, data):
    if ok: print(data["amount"], data["currency"])
    else:  print(data["code"]))

Without an SDK - to check a method by hand, or from a language with no asobi SDK yet - it is two frames on the socket. RPC rides the game WebSocket, so this is websocat rather than curl, and the socket must be authenticated first: an rpc.call on an unauthenticated socket is rejected, because rpc/0 is player-scoped and there is no player yet.

TOKEN=$(curl -sX POST https://your-host/api/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"username":"alice","password":"..."}' | jq -r .access_token)

printf '%s\n%s\n' \
  "{\"type\":\"session.connect\",\"cid\":\"1\",\"payload\":{\"token\":\"$TOKEN\"}}" \
  '{"type":"rpc.call","cid":"2","payload":{"protocol":1,"method":"quests.claim","params":{"quest_key":"daily_kills"}}}' \
  | websocat wss://your-host/ws

The reply carries the cid you sent, which is what lets several calls be in flight at once:

{"type":"rpc.ok","cid":"2","payload":{"result":{"reward":100}}}

Branch on code, never on message. The shape is the same in every SDK - a method name, a params object, and a reply that is either a result object or the shared error object - so a method you declare here is callable from all of them without a per-engine server change. flame_asobi is a Flame bridge over the Dart SDK rather than a protocol implementation of its own, so it inherits rpc from it.

The handler is (Params, Ctx), which is why the arity in rpc/0 is always 2:

-spec claim(asobi_rpc:params(), asobi_rpc:ctx()) -> asobi_rpc:reply().
claim(#{~"quest_key" := QuestKey}, #{player_id := PlayerId}) ->
    case asobi_quests:claim(PlayerId, QuestKey) of
        {ok, #{currency := C, amount := A}} ->
            {ok, #{quest_key => QuestKey, currency => C, amount => A}};
        {error, already_claimed} -> {error, ~"quests.already_claimed"};
        {error, {no_such, Key}}  -> {error, ~"quests.not_found", #{quest_key => Key}}
    end.

{ok, map()} | {error, Code} | {error, Code, Details}.

The failure half is a code, never a status and never an object you build yourself. Both are derived from the code - asobi_error:status/1 and asobi_error:object/2 - so two call sites cannot answer the same code differently, and a code you declared in codes/0 reaches the client as itself. It is the same dialect core's own controllers speak ({asobi_error, Code, Details}), so there is one shape to learn.

Ctx is #{player_id, session, method}. It may gain keys: match the ones you need with := and never match it exhaustively.

Everything else is a defect and answers internal with one logged line naming the method: a handler that raises, one that returns outside the contract, one declared at an arity other than 2, a result that cannot be JSON-encoded, and a code you did not declare in codes/0. The last one is how the closed code set survives a surface where the code is a runtime term the handler could have built out of params.

What the seam answers

CodeStatusWhen
rpc.unknown_method404no installed extension declares that method, or method is not a string
rpc.invalid_cid400cid missing, empty, over 64 bytes, or not printable ASCII
rpc.invalid_params400params is not a JSON object
invalid_payload400payload is not a JSON object
rpc.unsupported_protocol400protocol is not this node's version
unauthenticated401the socket has not completed session.connect
not_ready503migrations have not finished on this node
internal500the handler is at fault; see above

A rejected cid comes back on a frame carrying no cid at all, because there is nothing trustworthy to echo. That one reply cannot be correlated: a client that sends a malformed cid gets an answer it must match by shape, not by id.

Before dispatch

The socket applies two limits ahead of any of the above, so a chatty method has to be sized against them:

  • 64 KiB per text frame. A larger frame answers payload_too_large.
  • 60 messages per second per connection, in a fixed one-second window. Over it answers rate_limited.

Both answer on the legacy error frame with no cid, so neither is correlatable either. The budget is per connection, held in the socket's own state: it counts every frame the socket carries, not just rpc.call, and adding nodes does not widen it. The buckets that are counted per node are the HTTP and connect limiters - see Clustering.

Every declared method is player-scoped

The caller is the authenticated player on that socket; an unauthenticated socket is refused before the method is looked up. There is deliberately no per-method capability class: read | player_data | config | erasure is an operator vocabulary that a player never holds, so tagging a socket method with one would make it deniable for every caller the dispatcher has. An operator-only method goes in ops/0 instead.

Writing an operator action

rpc/0 is player-scoped, so an admin action - defining a quest, correcting a counter, anything a player must never call - has no home there. ops/0 is that home, reached on the ops plane by an operator credential:

-spec ops() -> asobi_extension:ops().
ops() ->
    #{~"define" => #{method => post,
                     mfa    => {asobi_quests_ops, define, 2},
                     class  => config},
      ~"summary" => #{method => get,
                      mfa    => {asobi_quests_ops, summary, 2},
                      class  => read}}.
POST /api/v1/ops/ext/quests/define
GET  /api/v1/ops/ext/quests/summary?filter=active

/api/v1/ops/ext/:extension/:action is the extension seam on the ops plane, and this is what puts something behind it. Core's own routes there are reads apart from erasing and exporting a player. You declare no operator routes - routes/0 mounts player and webhook surfaces, never this plane: core owns /ext/:extension/:action and dispatches every declared action behind it, the same way it owns one WebSocket frame type and dispatches rpc/0 behind that.

Know what gates it. On a stock deployment there is no ops_secret, so every bearer request is denied 403 and none of this is reachable. Once a secret is set, these routes are live whether or not the console is - console gates /console only, never the ops plane. A holder of the secret holds every capability class, so declaring class => config restricts which minted tokens reach an action, not which secret-holders do. erasure is the one class a console session does not get by default, so declaring it also keeps an action out of a browser unless the operator set console_erasure. See Operator console.

The console can invoke an ops action, and an extension can ship the screens that do it: React source under priv/console, composed into the console bundle by rebar3 asobi console. See Extending the operator console. /api/v1/ops/features reports ops for an extension that declares actions and console for one that ships screens, alongside lua, rpc and tables.

Same handler shape as rpc/0:

-spec define(map(), asobi_ops_extension:ctx()) -> asobi_rpc:reply().
define(#{~"key" := Key}, #{actor := #{id := ActorId}}) ->
    case asobi_quests:define(Key, ActorId) of
        {ok, Quest}          -> {ok, #{quest => Quest}};
        {error, name_taken}  -> {error, ~"quests.name_taken"}
    end.

Params is the decoded JSON body for a write and the parsed query string for a get. Ctx is #{actor, extension, action}, so recording who asked and what they reached needs no second lookup.

Readiness guards this plane as well as the socket: until migrations finish, every action answers not_ready (503).

Three things are core's, not yours:

  • class is the whole authorisation. read | player_data | config | erasure is the same vocabulary core's own ops routes carry. An action is admitted when its class is in the caller's capabilities and never otherwise. There is nothing to check inside your handler.

  • An undeclared action is denied, not 404. It has no class, and a route with no class is refused - so an unknown extension, an unknown action and a method the action does not answer all answer 403. Which extensions are installed is not something an unauthorised caller gets to enumerate.
  • Every method but get is audited. Core runs your function inside asobi_ops_audit:mutation/4 and writes the row from what it returned. You cannot opt out, and declaring a method other than get is what opts in.

What the audit records today

The audit path understands {ok, Succeeded, Failed} | {error, Reason}. An ops handler returns {ok, map()} | {error, Code} | {error, Code, Details}, so only the two-element {error, Code} matches. Everything else raises inside the audit write, which is caught and downgraded to an error-level log line naming the action, the exception class and the reason.

So today, a failing action returning {error, Code} records a durable row; a successful mutation and a raising one are logged, not recorded. The response to the caller is unaffected either way. Tracked as an open issue; do not build a compliance story on the row until it is closed.

Manifest validation

Each of these is a build failure at rebar3 asobi check, and again at boot:

  • the action is one non-empty path segment, with no / . ? # %
  • method is get, post, put or delete
  • class is read, player_data or config
  • mfa is {Module, Function, 2}

Declaring an HTTP route

routes/0 exists for two callers, and it is worth being blunt about which before showing the shape. It preserves REST surfaces - a subsystem extracted out of core keeps serving the exact paths every vendored SDK already calls - and it receives server-to-server webhooks, where the sender is a store's backend that could never hold a player token. A new client-facing feature should use rpc/0 instead: one rpc(method, params) symbol reaches every SDK with zero per-extension SDK work, while a new REST route has no SDK surface at all. That is a listing guideline, not a mechanical ban - the catalogue review asks "why is this a route and not an rpc method", and "it is a webhook" is an accepted answer.

routes() ->
    [#{path => ~"/api/v1/quests/board", method => get,
       mfa => {asobi_quests_controller, board, 1}, security => player},
     #{path => ~"/api/v1/quests/webhook/steam", method => post,
       mfa => {asobi_quests_controller, steam_notification, 1}, security => webhook}].

Routes live under your extension's own name, exactly like every other namespace you claim: /api/v1/quests/... for an extension named quests. A subsystem extracted out of core is the one exception - it keeps its historic core paths, and core's own table stops serving them in the same release.

Declared, not mounted: core's router mounts every entry at boot, inside its global plugin chain - body cap, rate limiter, security headers - which an extension can neither replace nor reorder. The handler is a Nova controller, applied as Module:Function(Req), so the arity is 1 - unlike the rpc/0 and ops/0 seams, whose handlers never see a request.

security picks what stands in front of it:

  • player - the authenticated player-token check every core /api/v1 route carries. Req arrives with auth_data holding player_id, and a bare request is refused exactly as it is on a core route. The default choice.
  • webhook - no player check, for the server-to-server case. The handler must authenticate its caller itself - a signature, a shared secret - because nothing else will. Webhook paths run in the dedicated webhook rate-limit bucket (10/s per caller, the same shape as iap) rather than the general 300/s api bucket: a webhook handler does signature crypto on every request, so the api budget would make it a CPU amplifier.

One path carries exactly one security class - declaring the same path under both is a build failure, not a coin-flip on which chain OPTIONS lands in.

Every declared path is a derived http claim (see Namespaces), and the comparison is structural: two paths collide when the routing tree cannot serve both - some request matches both patterns (/saves/:slot and /saves/:id are one claim), or one pattern diverges from the other at a binding at any depth, the shape that lets one route swallow lookups meant for the other. A collision with another extension, with any path a co-mounted application serves (core's own table, and nova_apps like nova_resilience's /health), or with a privileged plane prefix (/api/v1/ops, /api/v1/auth, /api/v1/iap, /console, /ws) is a build failure at rebar3 asobi check and a refusal at boot - a route can never be silently shadowed first-compiled-wins, and declaration order never matters. The other side of that coin is the 404 discipline: a path whose extension is not installed is simply absent from the table, answering 404 like any path that never meant anything. That discipline is path-level only - a mounted path answers a wrong-method request with 405 plus an allow header, exactly as core routes do, so the methods on declared paths are enumerable; that trade is accepted.

Each of these is a build failure, like the ops/0 list above:

  • path is /-rooted, with non-empty segments that are literals ([A-Za-z0-9_~-]) or :name bindings
  • method is get, post, put or delete
  • mfa is {Module, Function, 1}, and it names an exported function - a missing handler is refused here, not discovered as a 500
  • security is player or webhook, and one path carries one class
  • no two entries in one manifest serve the same path and method, and no two declare patterns that collide - the same path under several methods is fine
  • with owns().http named, every declared path is listed in it, verbatim, and every listed token is itself a /-rooted path

Writing a Lua binding

A game script calls the namespace as an ordinary part of game:

function on_player_kill(player_id, state)
    local result = game.quests.progress(player_id, 1)
    if result.error then
        game.log("warning", "quest progress failed: " .. result.error)
    end
end

The binding takes the declared arguments positionally, already decoded to the types args names, and returns the same envelope every persistence-style game.* call returns:

-spec progress(binary(), integer()) -> {ok, term()} | {error, binary()}.
progress(PlayerId, Amount) ->
    case asobi_quests:progress(PlayerId, Amount) of
        {ok, Count} -> {ok, Count};
        {error, _}  -> {error, ~"progress failed"}
    end.

Lua reads result.ok or result.error. Nothing is ever silently nil: a wrong or missing argument is { error = "argument 2 must be a integer" } at the script's own call site, and a binding that raises or returns outside the contract is an error result plus one logged line naming the function.

args types are binary, integer, number, boolean, table and any, one per mfa argument. Lua has a single number type, so a script writing 1 may hand over 1.0; a whole float satisfies integer.

effects is write or none, and it is not decoration: probe VMs re-run the whole script body to ask phases() and swap every write function for an inert stub, so a write declared none fires twice on every match creation.

vms decides which VM kinds see the binding, and may name match, world or zone. A match binding is absent from a world's zone VMs, and its namespace table is not even created there.

bot is refused at rebar3 asobi check, not ignored. A bot script is loaded with no game table at all - see Bots - so a binding declaring bot would install nothing, and a declaration that silently does nothing is a defect. Making it work was rejected: a bot has no players.id, so the argument every extension binding takes cannot be supplied. A bot decides from the state the match broadcasts and nothing more; put what it needs in that state.

Core calls {M, F, A} fully qualified rather than holding a fun, so a code upgrade takes effect without waiting for every live match VM to end.

Most of it is discovered

ThingHow asobi finds itYou declare
Migrationsapplication:get_key(App, modules), matched on m<14 digits>_nothing
SchemasSame module list, filtered by "exports table/0 and fields/0"nothing
Background jobsNothing at all. The job row names the worker modulenothing
Domain logicNothing. They are modules; other code calls themnothing
RPC handlersCannot be inferredrpc/0
Lua namespaceCannot be inferredlua/0
Operator actionsCannot be inferredops/0
Error codesCannot be inferred; the core set is closedcodes/0
Supervised processesOptionalsup/0
Namespace ownershipCannot be inferredowns/0

rebar3 asobi check warns that an extension declaring neither rpc/0 nor lua/0 is "reachable by nobody". The check counts those two only, so the warning also fires for an extension whose entire surface is ops/0, which is reachable. It is spurious for that case and safe to ignore; tracked as an open issue.

Prefer a library application

If your extension has processes, omit mod from your .app.src and declare children via sup/0.

asobi_sup  (one_for_one, 10/60)
  `- asobi_extension_sup       (one_for_one, 3/60)
       |- quests               (own restart budget)
       `- clans

Applications in a release are permanent by default, and in OTP a permanent application terminating takes the whole runtime with it. So a normal OTP app whose supervisor exceeds its restart intensity kills the node - matchmaking, presence, every live match. Under asobi_extension_sup an extension that exhausts its own budget goes dark, core logs which one, and the node survives. This is the ordinary BEAM pattern: Ecto repos, Oban and Phoenix endpoints are all started in the host's tree rather than by the library.

An application with its own mod also works; you then own the failure mode, and the operator has to mark it non-permanent in the release. With no mod there is no start/2 for one-time setup: ETS tables and config validation move into the init/1 of a supervised worker.

Per-extension restart limits default to 5 in 60 and are settable:

{asobi, [{extension_restart, #{intensity => 5, period => 60}}]}

sup/0 children are per node. A supervised gen_server holding state holds N copies of it across an N-node cluster, one per node, with nothing synchronising them; and matches and worlds do not migrate between nodes. See Clustering.

Boot order and readiness

The route table compiles during Nova's boot, inside nova_sup:init/1; migrations run afterwards, from asobi_app:start/2. An extension endpoint is therefore reachable before its tables exist, so both seams fail closed until migrations finish: every RPC call and every ops action answers not_ready (503) until then. You get this for free - there is nothing to call.

case asobi_readiness:guard() of
    ok -> dispatch(Extension, Action, Actor, Req);
    {error, _Object} -> {asobi_error, ~"not_ready"}
end.

Your sup/0 children are on the other side of that seam and get two guarantees, so none of them needs a retry path:

  • They start in the order sup/0 returns them, and after the children of every extension your application depends on. That is OTP's own child order and asobi_extensions:resolve/0's dependency order; nothing sorts either.
  • init/1 may query. Extension children start after migrations have run to completion, so the pool is up and every table - core's and yours - exists.

Two failure modes are worth stating plainly:

  • An invalid manifest set stops the node. asobi_extensions:resolve/0 raises {asobi_extensions, Problems} after logging each problem in prose, from inside Nova's route compilation. The node does not start. This is why rebar3 asobi check is the gate.
  • If migrations did not complete, asobi_extension_sup starts no extension at all and logs which ones it did not start, and every extension seam answers
    1. The alternative is every extension crash-looping into its own restart budget and going dark anyway. The marker is written once, before this supervisor exists, so it cannot flip later and there is nothing to retry.

Declaring dependencies

requires/0 names the subsystems and extensions this one calls:

requires() -> [economy].

A bare list of names, never version ranges. The names are core subsystems (economy, leaderboards, notifications, storage, tournaments, social, chat, iap, matches, world, votes, presence, timers) and other installed extensions. Which versions work together is the dependency pin's job - {asobi, "~> 0.83.0"} in your rebar.config, and the bundle's lockstep for a first-party set - so requires/0 says only that you call into a thing, never which of it.

Two things read it, both through the one rebar3 asobi check:

  • A name that resolves to nothing is refused. If a required name is neither a core subsystem nor an installed extension, the build fails and the boot backstop refuses, naming your extension and the missing dependency - not an undef the first time a client reaches the code that was never installed.
  • It orders boot. A requirement on another extension must be backed by an ordinary OTP application dependency, so the resolver already starts the provider first (the same dependency order your sup/0 children rely on). Declare requires => [leaderboards] and make your application depend on asobi_leaderboards; the requirement without the application dependency is refused, because nothing would guarantee the boot order. A requirement on an always-present core subsystem imposes no ordering.

The set a name resolves against is the union of core's subsystem names and the installed extensions' names, and that union is what keeps a requires correct when a core subsystem later ships as its own package: the name moves from the core side of the union to the extension side, and the requirement stays satisfied because the bundle installs the package. economy is a core subsystem you may depend on today and an extracted extension tomorrow, and requires => [economy] reads the same either way.

Namespaces

owns/0 reserves names. Two extensions claiming the same table, RPC prefix, Lua namespace or job queue is a build failure naming both claimants, and so is claiming a name core reserves.

The claim set is owns/0 plus what your own code already implies, and every kind derives:

KindDerived from
rpcthe prefixes in rpc/0 and the domains in codes/0
luathe namespaces in lua/0
tablestable/0 on your kura_schema modules
queuesqueue/0 on your shigoto_worker modules
httpthe paths in routes/0

So a collision is caught even before either extension has bothered with owns/0, and a queue you actually run is claimed whether or not you remembered to say so. That leaves owns/0 one job: the closed-set assertion. Naming a kind at all says "this is the whole set", so anything derived outside it is a build failure - which is what catches a worker on quests under an owns/0 saying quest.

http alone compares structurally rather than by name: two paths are one claim when the routing tree cannot serve both - a request matches both, or they diverge at a binding at any depth. An owns().http entry with no route behind it is a plain reservation, and reserves with the same exclusivity.

Core's reserved names derive from core itself by the same rules: Lua namespaces from asobi_lua_surface:reserved_namespaces/0, tables from core's schemas, queues from core's shigoto workers, route paths from every co-mounted application's route table - core's own plus nova_apps, so nova_resilience's /health, /ready and /live are as unclaimable as /api/v1/matches. Five plane prefixes (/api/v1/ops, /api/v1/auth, /api/v1/iap, /console, /ws) are reserved whole: nothing mounts under them, whatever it would resolve to.

Reserved RPC prefixes are the domains of asobi_error:core_codes/0 plus every core Lua namespace, because an RPC prefix and an error-code domain are the same token. So game, economy, leaderboard, storage, chat, spatial, zone and terrain are all refused as RPC prefixes as well as Lua namespaces - owning storage would mint codes inside core's closed code set.

Error codes

asobi_error's set is closed, so a code you have not declared answers 500 and logs as a core defect. Declare yours:

codes() -> #{~"quests.already_claimed" =>
               #{status => 409, message => ~"This quest was already claimed."},
             ~"quests.not_found" =>
               #{status => 404, message => ~"No quest exists with this id."}}.

{asobi_error, ~"quests.already_claimed"} then answers 409 with the shared object and logs nothing.

Every code must be <domain>.<name> with the domain an RPC prefix you own, so rebar3 asobi check refuses a code in core's namespace or another extension's, and refuses a bare one. status must be 100-599 and message non-empty. The set is read once at boot, from the manifest, so it stays closed per deployment - a string arriving in a request or a Lua script cannot become a code.

Tables

Three distinct things, and only one creates a table:

  1. The schema - a kura_schema module. Describes.
  2. The migration - generated by rebar3 kura compile, never hand-written. Creates.
  3. owns/0 - reserves the name. Creates nothing.

Rules:

  • An extension may foreign-key into core. Core never foreign-keys into an extension.
  • Extensions never alter core tables. Use a sidecar table keyed on player_id. Two extensions both adding level to players is unrecoverable, and core adding the same column later is worse.
  • An extension FK into players.id must cascade or declare an erase path. A blanket cascade was rejected: cascading players into iap_transactions would destroy real-money purchase records that a refund or chargeback dispute still needs.

Your migrations run from your own application: kura discovers them through asobi_repo:migration_apps/0, inside core's transaction and under one advisory lock.

A table extracted out of core

owns/0 and the migration that creates a table are separable, and one case needs them separate: a table that used to be core's.

asobi_seasons owns seasons, but the CREATE TABLE sits in an asobi migration that has already run against live databases, and shares a file with a table core kept. So the extension ships a schema and no migration, and asobi keeps the history it cannot honestly disown. Ownership is the manifest's job; history is append-only.

The operational consequence: core has no seasons schema, so rebar3 kura compile will offer to drop the table. Decline it. The same applies to any table extracted this way, and this is the shape of every future extraction. It only applies to a table core once created: a table an extension invents is created by the extension's own migration, like quests.

Deleting a player

Cascade or declare an erase path - and an undeclared on_delete lowers to no_action, so the foreign key rebar3 kura compile generates refuses the delete until you have picked one. The first row your extension writes for a player makes that player undeletable otherwise. The symptom of declaring neither is guests quietly ceasing to be reaped.

Cascade is one line on the association:

#kura_assoc{
    name = player, type = belongs_to, schema = asobi_player,
    foreign_key = player_id, on_delete = cascade
}

rebar3 kura compile carries that into the generated migration as ON DELETE CASCADE, and there is nothing else to write.

Cascade is right for progress rows and wrong for a financial or audit row - the case that rejected a blanket cascade in the first place. Implement erase_player/1 instead:

-spec erase_player(binary()) -> ok | {error, term()}.
erase_player(PlayerId) ->
    {ok, _} = asobi_repo:delete_all(by_player(asobi_quest_progress, PlayerId)),
    ok.

Who calls it, and when

asobi_player_erase does. That is the single place core deletes a player, and it has two entry points: asobi_player_erase:run/1 from an Erlang shell, and POST /api/v1/ops/players/:id/erase on the ops plane. The guest reaper is one more caller of the same code rather than a second implementation of it, so there is one erasure path and your callback is on it whichever way the deletion was asked for.

Core calls it inside its own transaction, before deleting any of its own rows, once per installed extension in dependency order. Do not open a transaction of your own. Extensions run before core so an erase path can still read the player's core rows.

Erasure is atomic across every extension. Returning {error, Reason} or raising aborts the whole deletion - no extension's rows go, core's rows stay, the player survives, and one logged line names the extension and the reason. So an erase path doing work the transaction cannot undo, such as deleting a remote object, must be idempotent: a later extension's failure rolls back everything around it and the deletion is retried.

Omit erase_player/1 when your rows cascade: it is the alternative to that declaration, not a second copy of it. Cascade lives on the column because the database is what enforces it, which is why this is a callback and not an owns/0 key.

The third option: sever the reference

Delete the rows, or null the player reference and keep the row. Core does both in one function and it is worth reading as the worked example, because a receipts table is exactly where authors get stuck.

asobi_player_erase:steps/1 deletes eleven tables and severs two:

  • iap_transactions.player_id is set to NULL. The receipt carries a provider, a store transaction id and a product id, and a refund or chargeback dispute needs it long after the account is gone. Statutory retention beats erasure for that row.
  • groups.creator_id is set to NULL. Deleting the group to free the key would destroy every other member's data.

Everything else goes. That is the anonymisation: every player-referencing table in core stores a bare uuid and nothing else about the person, so once players and player_identities are gone the surviving id resolves to nobody. Core does not mint a tombstone player row, and neither should you - a tombstone is a record about a person you were told to erase.

Core's own foreign keys are all no_action and its erasure enumerates its children explicitly rather than delegating to the database. That is deliberate, and it is the reason a blanket ON DELETE CASCADE migration is refused rather than merely discouraged: a database cascade fires below the transaction's control flow, so erase_player/1 would never be called at all and the receipts would be destroyed silently.

Removing the package, and the rows it leaves

Uninstalling an extension does not drop its tables or delete its rows: a package removal is a code change, not a data migration, because destroying player progress stays a deliberate act. But the rows outlive the code that swept them. The foreign key into players.id is still no_action, and nothing runs the extension's erase_player/1 any more, so the next erasure of a player those rows reference cannot delete the players row - and without the policy below that surfaces as a bare Postgres constraint error and leaves the player permanently un-eraseable.

Core names the blocker instead. asobi_player_erase catches the foreign_key_violation and returns {error, {orphaned_extension_rows, Table}}, naming the referencing table the absent package owns; the ops route (POST /api/v1/ops/players/:id/erase) answers ops.orphaned_extension_rows (409) with that table in details, and the guest reaper logs it. The player is not erased and the transaction rolls back. There are two honest ways out:

  • Keep the package installed. Its erase_player/1 stays on the erasure path and keeps sweeping, so erasure never breaks in the first place.
  • Purge its tables. Delete the extension's rows deliberately - asobi ext remove (asobi-cli) prints the per-table statements derived from the package's owns/0 table claims, run by the operator. Destroying player progress stays a decision, never a side effect - but so does keeping GDPR erase working.

export_player/1

Core exports a player - GET /api/v1/ops/players/:id/export - and the payload names every installed extension under an extensions key: the data your export_player/1 returned, or a skipped marker when you do not export one.

erase_player/1 earns its keep because the foreign key forces you to answer: skip it and the player row physically cannot be deleted. An export callback has no such forcing function - an extension that skipped an unmarked one would produce a silently incomplete export and nothing would fail - so the artefact itself is the forcing function. A skipped extension is a visible marker in the export a data subject or auditor reads, not an absence nobody can detect.

-spec export_player(binary()) -> {ok, #{binary() => term()}} | {error, term()}.
export_player(PlayerId) ->
    {ok, Rows} = asobi_repo:all(by_player(asobi_quest_progress, PlayerId)),
    {ok, #{~"quest_progress" => [maps:with([quest_id, counter], Row) || Row <- Rows]}}.

The map keys are your own section names, mirroring core's per-table sections. Apply the same rule core does: positive allowlists via maps:with/2, never subtractive filters - an extension carrying a token or secret column is one schema field away from exporting it otherwise. And every row you return must be one this player owns - core cannot check that for you. Where one column holds several players' data, lift out this player's part rather than exporting the column whole, the way core exports only the requester's own choice from votes.votes_cast.

A missing callback is a marker; a failing one fails the export. Returning {error, _}, raising, or returning a section json:encode/1 cannot encode means data was promised and not delivered - exactly the silent incompleteness the marker exists to prevent - so the whole request answers 500 ops.export_incomplete and no artefact is produced. There is no transaction: core's export is a sequence of plain reads, and yours run in the same untransacted pass, after core's own sections.

Counters

update_all/2 SETs literals and kura's on_conflict overwrites, so neither accumulates. asobi_repo:increment/3 is the primitive for the counter every progress-shaped extension needs:

{ok, Row} = asobi_repo:increment(
    asobi_quest_progress,
    #{player_id => PlayerId, quest_id => QuestId},
    #{counter => 1}
).

One statement, roughly INSERT ... ON CONFLICT (...) DO UPDATE SET "counter" = "quest_progress"."counter" + EXCLUDED."counter", so two concurrent callers both land and the row is created if it is missing. The conflict target must be a primary key or covered by a unique index, and a field cannot be both a key and a counter.

There is no general query/2. Every identifier increment/3 interpolates is a field of the schema you pass and every value is a bound parameter, which is a promise raw SQL through the seam could not make.

Testing

Core's suites under test/extensions/ are the worked examples.

A fixture extension needs no .app file and no separate build. asobi_fixture_app:install/3 hands application:load/1 an application spec directly, with your manifest module in its modules list - exactly what discovery reads. asobi_fixture_quests_extension declares all of rpc/0, lua/0, ops/0, codes/0, routes/0 and owns/0; asobi_fixture_minimal_extension is the info/0-only case; asobi_fixture_clans_extension is a second extension whose application depends on the first, so start order is observable.

A declared route only exists in a table compiled after the extension was installed, so asobi_extension_routes_SUITE installs its fixture and then restarts the node - there is no memo to clear after the fact, unlike the RPC registry. Route validation and mounting need no server at all: asobi_extension_routes_tests checks the compiled table with nova_router:compile/1, the same way asobi_router_tests does.

Exercise rpc/0 without a socket by calling the dispatcher directly. asobi_rpc:handle(Cid, Payload, Caller) takes the payload map and a caller of #{player_id, session} or the atom unauthenticated, and returns {Cid, Outcome} - no cowboy, no connection. asobi_rpc_tests is the pattern: reset the registry, install the fixture, asobi_extensions:resolve(), asobi_readiness:mark_ready(), call, assert. Reset both in teardown, because the registry and the readiness marker are persistent_term. asobi_ops_extension:handle/1 takes a cowboy_req map, so the ops seam is tested from a hand-built map carrying bindings and auth_data; asobi_ops_extension_tests shows the shape.

rebar3 asobi check belongs in your host release's CI, not the extension's own: it validates a whole installed set, and an extension built alone has nothing to collide with. Run it after compile (the provider already depends on it) and before anything boots the node.

Where the logic goes

asobi_quests/
  src/
    asobi_quests.app.src           applications: [kernel, stdlib, asobi]
    asobi_quests_extension.erl     the declarations
    asobi_quests.erl               THE DOMAIN LOGIC - plain Erlang
    asobi_quests_rpc.erl           thin: decode, call domain, encode
    schemas/     asobi_quest.erl, asobi_quest_progress.erl
    migrations/  m20260803174500_create_quests.erl   (generated)
    workers/     asobi_quests_rollover_worker.erl

asobi_quests.erl knows nothing about HTTP, RPC, Lua or the console. Each of those is a thin adapter over it, which is what makes one implementation reachable from a client call, a background job and a Lua binding.

Not sandboxed

An extension runs in the same node, the same supervision tree and with the same database credentials as core. asobi_repo is unrestricted, and os:cmd/1, open_port/2 and load_nif/2 are all reachable. Its migrations run with full DDL privilege. Treat installing one as you would treat any dependency with production credentials.

Next steps