Skip to content

feat(extensions): make rpc/0 and lua/0 reachable - #365

Merged
Taure merged 3 commits into
mainfrom
feat/wave2b-rpc-lua-dispatch
Aug 4, 2026
Merged

feat(extensions): make rpc/0 and lua/0 reachable#365
Taure merged 3 commits into
mainfrom
feat/wave2b-rpc-lua-dispatch

Conversation

@Taure

@Taure Taure commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Wave 2b, core half. rpc/0 and lua/0 stop being declarations that nothing
reads.

The first real extension (widgrensit/asobi_quests) reported this as gap 1, and
it was the most damning Wave 4 finding: asobi_extensions:resolve/0 had three
callers and none touched rpc or lua; asobi_lua_api:install/2 installed a
hardcoded list with no extension hook; there was no rpc.call handler anywhere
in src/. An extension could declare a method and a namespace, have both fully
validated, 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": {}}}}
  • cid is 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 cid is not echoed back - there is nothing trustworthy to echo.
  • params and result are always objects, so either can grow a field
    without breaking a shipped client.
  • protocol versions the payload, not the frame type, so a future version
    is 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_rpc and guides/extensions.md:

-spec claim(asobi_rpc:params(), asobi_rpc:ctx()) -> asobi_rpc:reply().

(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 gave
extensions 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 is
always 2 - and asobi_extensions now refuses any other arity at
rebar3 asobi check
, turning a 500 on the first client call into a build
failure. asobi_rpc keeps a backstop clause for a manifest that got past both
gates.

Readiness

Wired to the existing asobi_readiness:guard/0 seam: every dispatch answers
not_ready (503) until migrations have run, because the route table compiles
inside nova_sup:init/1 and 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 that
cannot be JSON-encoded, or an undeclared code becomes internal, each with
one 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/0
is 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. So asobi_rpc carries the runtime equivalent - it emits only a code
asobi_error:defined/1 accepts, which is core's set plus every installed
manifest's - and takes the same documented scanner exemption asobi_error
itself has, now pinned by a test rather than by trust.

The encode check also happens in asobi_rpc, where the method is known, so a
handler 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_protocol
and rpc.invalid_params. That also reserves the rpc prefix against
extensions, for free, through the existing derivation in
asobi_extension_reserved.

Capability - gap 8, argued and declined

quests.define could not be declared because an extension cannot mark a method
operator-only. No capability class was added to rpc/0, for three reasons:

  1. read | player_data | config (ADR 0007) is an operator vocabulary,
    minted by asobi_ops_auth for the ops plane and never held by a player. A
    socket 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.
  2. The reachable home for an operator-only extension method is the ops plane,
    and that plane is read-only by assertion today
    (asobi_ops_tests:ops_plane_serves_no_write_method_test/0). Opening it is a
    decision about that plane - Wave 2c's token minting, role-to-cap mapping,
    CORS and rate limiting on a public port - not a manifest change.
  3. Adding a key whose only consumer does not exist, to a manifest the design
    says explicitly must not freeze, is what owns/0 already 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.erl

An extension's lua/0 namespace is now installed. Per the design, and by
reading 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 same
    parent-before-child order (set_table_keys does not auto-vivify).
  • Installed in the PreInstall window, the same one core uses - Lua closures
    capture _ENV at compile time.
  • Declared effects goes through the existing pick/3 dry-run swap, so an
    extension's write binding is stubbed in a probe VM exactly like core's.
    Without it a top-level game.quests.progress(...) fires twice per match
    creation.
  • {M, F, A} is applied fully qualified, never captured as a fun.

vms is now honoured. A world VM's Ctx is shape-identical to a match VM's, so
the kind is stated at the install site (vm => match | world | zone) rather than
guessed; zone_pid stays as a fallback. A binding absent from a VM kind does not
even get its namespace table created there. Bot VMs never reach install/2, so
vms => [bot] installs nothing today - documented.

The binding signature, specified

-spec progress(binary(), integer()) -> {ok, term()} | {error, binary()}.

Positional declared arguments, decoded to the types args names, returning the
same { ok = ... } / { error = "..." } envelope every persistence-style
game.* 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, and
a 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 is
    installed as a loaded OTP application the way a dependency is; rpc.call goes
    in over cowboy and rpc.ok / rpc.error comes back.
  • test/extensions/asobi_extension_lua_tests.erl - 13 cases through a real
    Luerl state
    , running Lua source. game.quests.progress('p-1', 3) reaches
    asobi_fixture_quests_lua:progress/2, which records the call, so "the binding
    was reached" is distinguishable from "something plausible came back".
  • test/extensions/asobi_rpc_tests.erl - 16 dispatcher cases: the call lands in
    the 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 manifest
already named and that never existed.

Docs

guides/extensions.md documented rpc/0 and lua/0 as if they worked. Now
they do, and both calling conventions are written down - the alternative is a
second extension inventing a third shape. guides/websocket-protocol.md gets
the frame reference and the core code table; guides/lua-scripting.md notes
game.<extension>.*. Conformance fixtures rpc.ok.json and rpc.error.json
added (the protocol coverage test demanded them, correctly).

Checks

fmt --check, xref, dialyzer clean. 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:

Reverted Result
extension bindings dropped from asobi_lua_api asobi_extension_lua_tests 11 of 13 fail
rpc.call clause removed from asobi_ws_handler asobi_rpc_SUITE 6 of 6 fail
arity gate loosened in asobi_extensions asobi_extensions_tests 1 fails

The 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 the
negative controls.

Not in this PR

  • SDK work. Core half only.
  • The module.event envelope (Wave 2b item 2) - already shipped as
    module.message / module.error.
  • Conformance-fixture sync automation across seven repos (Wave 2b item 4).
  • Gap 8's reachable home: an ops write plane.

`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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🟡 Code Coverage — 74.5%

6541 of 8777 lines covered.

Taure added 2 commits August 4, 2026 13:55
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
Taure merged commit 3233566 into main Aug 4, 2026
16 checks passed
@Taure
Taure deleted the feat/wave2b-rpc-lua-dispatch branch August 4, 2026 21:24
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant