feat(extensions): make rpc/0 and lua/0 reachable - #365
Merged
Conversation
`rpc/0` and `lua/0` were validated and then read by nothing.
`asobi_extensions:resolve/0` had three callers and none touched either,
`asobi_lua_api:install/2` installed a hardcoded namespace list, and there
was no rpc.call handler anywhere in src. An extension could declare a
method and a namespace, have both accepted, and be reachable by nobody.
The RPC dispatcher (`asobi_rpc`):
- `rpc.call` / `rpc.ok` / `rpc.error` on the existing socket, `protocol: 1`
in the payload, `params` and `result` always objects.
- `cid` is required and validated here (1-64 printable ASCII), unlike the
optional echo elsewhere on the socket: it is the only way a client pairs
a reply with its call, and it is reflected into a frame. A rejected cid
is not echoed back.
- The handler contract is specified: `(Params, Ctx) -> {ok, map()} |
{error, Code} | {error, Code, Details}`, so `rpc/0` targets are arity 2
and `asobi_extensions` now refuses anything else at `rebar3 asobi check`.
The failure half is a code, not a status: status and object both derive
from it, and #364 gave extensions their own code domain, so an
extension's error surfaces as its own code rather than `internal`.
- `asobi_readiness:guard/0` runs before every dispatch, so the window
between route compilation and migrations answers `not_ready` (503).
- Only a raise, a return outside the contract, a non-2 arity or a result
that cannot be encoded becomes `internal`, each with one logged line.
The Lua injector (`asobi_lua_api`):
- Extension namespaces are pre-created alongside core's and installed in
the same PreInstall window, so a script's closures can see them.
- Declared `effects` runs through the existing `pick/3` dry-run swap, so a
`write` binding is stubbed in a probe VM exactly like core's.
- `{M, F, A}` is applied fully qualified, never captured as a fun.
- `vms` is honoured: the install site now states its VM kind, and a binding
absent from that kind does not even get its namespace table created.
- The binding contract is specified: positional declared arguments,
decoded to their `args` types, returning `{ok, term()} | {error, binary()}`
through the same `{ ok = }` / `{ error = }` envelope core uses. A wrong
or missing argument is a legible error at the script's call site.
No capability class on `rpc/0`. `read | player_data | config` (ADR 0007)
is an operator vocabulary a player never holds, so tagging a socket method
with one makes it deniable for every caller the dispatcher has - another
unreachable declaration. The reachable home for an operator-only method is
the ops plane, which is read-only by assertion today.
🟡 Code Coverage — 74.5%6541 of 8777 lines covered. |
ex_doc's `mod:fun/arity` form resolves exported functions; lua/0 is a behaviour callback, so it needs the `c:` prefix the codebase already uses elsewhere (see asobi_join_ctx referencing c:asobi_match:join/3). Four warnings on this branch, none on main. ex_doc is on the pre-push checklist and was the one gate this PR skipped.
Taure
added a commit
that referenced
this pull request
Aug 4, 2026
The last gap the first extension filed and #365 deliberately left open: an extension with an admin action had nowhere to put it. rpc/0 is player-scoped by construction, and read | player_data | config (ADR 0007) is an operator vocabulary no player ever holds, so tagging a socket method with a capability class would have made it deniable for every caller that dispatcher has. The reachable home is the ops plane, so that is where this mounts. Extensions still contribute no routes (ADR 0003). Core owns exactly one, /api/v1/ops/ext/:extension/:action, and dispatches every declared action behind it - the same shape as owning one WebSocket frame type and dispatching rpc/0 behind that. The route table stays core's. An action declares its own capability class, and asobi_ops_caps reads that manifest entry on the same path it reads its own table, so the plane still has exactly one authorisation decision. An action nobody declared has no class, and a route with no class is denied - which is why an unknown extension, an unknown action and a method the action does not answer are all 403 from the security callback rather than 404 from the dispatcher. Which extensions are installed is not something an unauthorised caller gets to enumerate. Every method but get is wrapped in asobi_ops_audit:mutation/4 before the handler runs, so an extension cannot write on this plane without a durable row naming the operator. Declaring a method other than get is what opts in; there is no way to opt out. The handler contract is the RPC one, unchanged - (Params, Ctx) returning {ok, map()} | {error, Code} | {error, Code, Details} - because a second shape would be a second thing to learn for no reason. A code the extension never declared is a defect here exactly as it is on the RPC seam: a handler could have built it out of params. This is the ops plane's first non-GET route. The meta-test that asserted the plane serves no write method now asserts core's own routes serve none, and a second one pins that exactly one route carries a write method, so the exception cannot widen quietly.
Taure
added a commit
that referenced
this pull request
Aug 4, 2026
The last gap the first extension filed and #365 deliberately left open: an extension with an admin action had nowhere to put it. rpc/0 is player-scoped by construction, and read | player_data | config (ADR 0007) is an operator vocabulary no player ever holds, so tagging a socket method with a capability class would have made it deniable for every caller that dispatcher has. The reachable home is the ops plane, so that is where this mounts. Extensions still contribute no routes (ADR 0003). Core owns exactly one, /api/v1/ops/ext/:extension/:action, and dispatches every declared action behind it - the same shape as owning one WebSocket frame type and dispatching rpc/0 behind that. The route table stays core's. An action declares its own capability class, and asobi_ops_caps reads that manifest entry on the same path it reads its own table, so the plane still has exactly one authorisation decision. An action nobody declared has no class, and a route with no class is denied - which is why an unknown extension, an unknown action and a method the action does not answer are all 403 from the security callback rather than 404 from the dispatcher. Which extensions are installed is not something an unauthorised caller gets to enumerate. Every method but get is wrapped in asobi_ops_audit:mutation/4 before the handler runs, so an extension cannot write on this plane without a durable row naming the operator. Declaring a method other than get is what opts in; there is no way to opt out. The handler contract is the RPC one, unchanged - (Params, Ctx) returning {ok, map()} | {error, Code} | {error, Code, Details} - because a second shape would be a second thing to learn for no reason. A code the extension never declared is a defect here exactly as it is on the RPC seam: a handler could have built it out of params. This is the ops plane's first non-GET route. The meta-test that asserted the plane serves no write method now asserts core's own routes serve none, and a second one pins that exactly one route carries a write method, so the exception cannot widen quietly.
This was referenced Aug 5, 2026
Taure
added a commit
to widgrensit/asobi_quests
that referenced
this pull request
Aug 7, 2026
The guide opened by telling readers that game.quests.* and the RPC methods need core machinery "not built yet" and were "declared and inert". That stopped being true: asobi_lua_api installs extension bindings per VM, and asobi_rpc dispatches rpc.call from asobi_ws_handler. Closed by widgrensit/asobi#365. The guide was advertising the main feature as non-functional. Both the guide and asobi_quests_rpc's moduledoc showed the call as asobi.rpc("quests.list", {}), which is no SDK's API. It is ws.rpc in JS, rpc_call in Godot and realtime:rpc in Defold and LOVE. The old form also used Lua table syntax for what is a client call. The replacement destructures what the handlers actually return: list answers #{quests => [...]}, and claim answers quest_key, currency and amount. My first draft of the claim example read a `reward` key, which does not exist - checked against asobi_quests_rpc:claim/2 rather than assumed. README is untouched. Its gap list is a historical record that says so, and already marks this one closed at the top.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Wave 2b, core half.
rpc/0andlua/0stop being declarations that nothingreads.
The first real extension (widgrensit/asobi_quests) reported this as gap 1, and
it was the most damning Wave 4 finding:
asobi_extensions:resolve/0had threecallers and none touched
rpcorlua;asobi_lua_api:install/2installed ahardcoded list with no extension hook; there was no
rpc.callhandler anywherein
src/. An extension could declare a method and a namespace, have both fullyvalidated, and be reachable by nobody.
The RPC dispatcher -
src/extensions/asobi_rpc.erl{"type": "rpc.call", "cid": "c-1", "payload": {"protocol": 1, "method": "quests.claim", "params": {}}} {"type": "rpc.ok", "cid": "c-1", "payload": {"result": {"reward": 100}}} {"type": "rpc.error", "cid": "c-1", "payload": {"error": {"code": "quests.already_claimed", "message": "...", "details": {}}}}cidis required and validated server-side (1-64 printable ASCII),unlike the optional echo the rest of the socket takes. An RPC reply is
useless without correlation, and the value is reflected into a frame. A
rejected
cidis not echoed back - there is nothing trustworthy to echo.paramsandresultare always objects, so either can grow a fieldwithout breaking a shipped client.
protocolversions the payload, not the frame type, so a future versionis a rejection a client can read rather than an
unknown_type.The handler signature, specified
Quests invented
handler(Params, Ctx) -> {ok, map()} | {error, Status, Object}because nothing specified one. It is now specified in
m:asobi_extension,m:asobi_rpcandguides/extensions.md:(Params, Ctx) -> {ok, map()} | {error, Code} | {error, Code, Details}.The failure half is a code, not a status and an object. Both are derived
from the code (
asobi_error:status/1,asobi_error:object/2), #364 gaveextensions their own code domain, and it is the dialect core's own controllers
already speak. A handler returning a status would let two call sites answer the
same code differently and would put object construction in every extension.
Because every target is applied as
Module:Function(Params, Ctx), the arity isalways 2 - and
asobi_extensionsnow refuses any other arity atrebar3 asobi check, turning a 500 on the first client call into a buildfailure.
asobi_rpckeeps a backstop clause for a manifest that got past bothgates.
Readiness
Wired to the existing
asobi_readiness:guard/0seam: every dispatch answersnot_ready(503) until migrations have run, because the route table compilesinside
nova_sup:init/1and migrations run after it.Errors
An extension's failure surfaces as its own code, provided it declared it in
codes/0. A raise, a return outside the contract, a non-2 arity, a result thatcannot be JSON-encoded, or an undeclared code becomes
internal, each withone logged line naming the method.
That last one is the interesting case, and
asobi_error_contract_tests:every_error_response_uses_the_shared_object_test/0is what forced it: every other call site's code is a binary literal it checks at
build time, and a handler's is a runtime term it could have built out of
params. Soasobi_rpccarries the runtime equivalent - it emits only a codeasobi_error:defined/1accepts, which is core's set plus every installedmanifest's - and takes the same documented scanner exemption
asobi_erroritself has, now pinned by a test rather than by trust.
The encode check also happens in
asobi_rpc, where the method is known, so ahandler returning a pid is reported as that method's failure rather than
crashing the connection process into a generic socket error.
Core adds
rpc.unknown_method,rpc.invalid_cid,rpc.unsupported_protocoland
rpc.invalid_params. That also reserves therpcprefix againstextensions, for free, through the existing derivation in
asobi_extension_reserved.Capability - gap 8, argued and declined
quests.definecould not be declared because an extension cannot mark a methodoperator-only. No capability class was added to
rpc/0, for three reasons:read | player_data | config(ADR 0007) is an operator vocabulary,minted by
asobi_ops_authfor the ops plane and never held by a player. Asocket method tagged with one would be deniable for every caller this
dispatcher has - another declaration nothing can reach, which is the exact
defect this PR exists to close.
and that plane is read-only by assertion today
(
asobi_ops_tests:ops_plane_serves_no_write_method_test/0). Opening it is adecision about that plane - Wave 2c's token minting, role-to-cap mapping,
CORS and rate limiting on a public port - not a manifest change.
says explicitly must not freeze, is what
owns/0already teaches against.Gap 8 therefore stays open with a named home. Worth a ticket against Wave 2c.
The Lua injector -
src/lua/asobi_lua_api.erlAn extension's
lua/0namespace is now installed. Per the design, and byreading how core's own surface does each of these rather than inventing a
parallel path:
[~"game", NS]is pre-created alongside core's reserved list, in the sameparent-before-child order (
set_table_keysdoes not auto-vivify).capture
_ENVat compile time.effectsgoes through the existingpick/3dry-run swap, so anextension's
writebinding is stubbed in a probe VM exactly like core's.Without it a top-level
game.quests.progress(...)fires twice per matchcreation.
{M, F, A}is applied fully qualified, never captured as a fun.vmsis now honoured. A world VM'sCtxis shape-identical to a match VM's, sothe kind is stated at the install site (
vm => match | world | zone) rather thanguessed;
zone_pidstays as a fallback. A binding absent from a VM kind does noteven get its namespace table created there. Bot VMs never reach
install/2, sovms => [bot]installs nothing today - documented.The binding signature, specified
Positional declared arguments, decoded to the types
argsnames, returning thesame
{ ok = ... }/{ error = "..." }envelope every persistence-stylegame.*call returns. Nothing is silently nil: a wrong or missing argument is{ error = "argument 2 must be a integer" }at the script's own call site, anda binding that raises or returns outside the contract is an error result plus
one logged line naming the function.
Proof it is reachable
A fixture extension whose declarations are actually called:
test/asobi_rpc_SUITE.erl- 6 cases over a real socket. The fixture isinstalled as a loaded OTP application the way a dependency is;
rpc.callgoesin over cowboy and
rpc.ok/rpc.errorcomes back.test/extensions/asobi_extension_lua_tests.erl- 13 cases through a realLuerl state, running Lua source.
game.quests.progress('p-1', 3)reachesasobi_fixture_quests_lua:progress/2, which records the call, so "the bindingwas reached" is distinguishable from "something plausible came back".
test/extensions/asobi_rpc_tests.erl- 16 dispatcher cases: the call lands inthe module, params and ctx arrive, every error branch maps, both gates hold.
test/extensions/asobi_extensions_tests.erl- the new arity gate.The two fixture handler modules (
asobi_fixture_quests_rpc,asobi_fixture_quests_lua) are the ones the existing quests fixture manifestalready named and that never existed.
Docs
guides/extensions.mddocumentedrpc/0andlua/0as if they worked. Nowthey do, and both calling conventions are written down - the alternative is a
second extension inventing a third shape.
guides/websocket-protocol.mdgetsthe frame reference and the core code table;
guides/lua-scripting.mdnotesgame.<extension>.*. Conformance fixturesrpc.ok.jsonandrpc.error.jsonadded (the protocol coverage test demanded them, correctly).
Checks
fmt --check,xref,dialyzerclean. Full eunit 1449 tests, 0 failures.CT
asobi_rpc_SUITE+asobi_ws_SUITE+asobi_lua_SUITE: 22 passed.Each change was reverted and the tests re-run, to check none of them is vacuous:
asobi_lua_apiasobi_extension_lua_tests11 of 13 failrpc.callclause removed fromasobi_ws_handlerasobi_rpc_SUITE6 of 6 failasobi_extensionsasobi_extensions_tests1 failsThe two Lua cases that survive the revert are the two that assert absence
(
a_binding_is_absent_from_a_vm_it_does_not_declare,nothing_is_installed_when_no_extension_is), which is correct - they are thenegative controls.
Not in this PR
module.eventenvelope (Wave 2b item 2) - already shipped asmodule.message/module.error.