Canonical caller and manifest-level authorization helpers.
New protocols opt into enforcement with
:authorization {:caller :member} on a capability definition. Legacy
capabilities without that key retain their current behavior while they are
migrated deliberately. Actor identity always comes from MoM's top-level
:caller envelope, never from :event/data.
Canonical caller and manifest-level authorization helpers.
New protocols opt into enforcement with
`:authorization {:caller :member}` on a capability definition. Legacy
capabilities without that key retain their current behavior while they are
migrated deliberately. Actor identity always comes from MoM's top-level
`:caller` envelope, never from `:event/data`.Payload-bound command idempotency for pure Game Master transitions.
Payload-bound command idempotency for pure Game Master transitions.
Identity: sign-in, session and seat resolution for a Game Master.
Deliberately NOT under game.protocols. Everything in that package is a
pure event handler with a manifest, dispatched from MoM through
/v1/gm/handle and returning :updated-state. This is the opposite shape: HTTP
routes served to a browser, proxying MoM, holding no game state and
advertising no handlers. It belongs with the runtime concerns (config,
dispatch, heartbeat) rather than the game protocols.
The flow, end to end:
A seat is a game membership owned by the signed-in human, matched by
owner-id or owner-email. Most authenticated reads are seat-scoped, so
require-selected-seat is the usual entry point for a route.
Game-specific behaviour is injected through an options map rather than
baked in — see session-context.
Identity: sign-in, session and seat resolution for a Game Master.
Deliberately NOT under `game.protocols`. Everything in that package is a
pure event handler with a manifest, dispatched from MoM through
/v1/gm/handle and returning :updated-state. This is the opposite shape: HTTP
routes served to a *browser*, proxying MoM, holding no game state and
advertising no handlers. It belongs with the runtime concerns (config,
dispatch, heartbeat) rather than the game protocols.
The flow, end to end:
1. the browser GETs /auth.json from the GM it is talking to
2. it redirects to the hosted auth app with ?config=<that url>
3. the auth app signs the user in and redirects either to a configured
app path or to the legacy /auth/callback endpoint on this GM
4. the browser stores the query-string token and sends it as a Bearer
header (the legacy callback first bounces that query to the app)
A *seat* is a game membership owned by the signed-in human, matched by
owner-id or owner-email. Most authenticated reads are seat-scoped, so
`require-selected-seat` is the usual entry point for a route.
Game-specific behaviour is injected through an options map rather than
baked in — see `session-context`.Idempotent startup seed for the Market taxonomy. Posts register-category and register-hashtag events to MoM as the Game Master so the marketplace UI has a non-empty set of categories and hashtags to render on day one.
Once a real curator process exists this seeder can go away.
Idempotent startup seed for the Market taxonomy. Posts register-category and register-hashtag events to MoM as the Game Master so the marketplace UI has a non-empty set of categories and hashtags to render on day one. Once a real curator process exists this seeder can go away.
The tools a cutover needs (mom/docs/phase-2/02-state-migration.md): prove that an import from MoM is faithful, and write the GM's state back into MoM's shape so the routed path stays a real fallback after the GM has taken writes.
diff-state compares a MoM export with what is in the store. It is for checking an
IMPORT: protocol rows must match exactly, so run it before the GM takes writes.import-checked! imports into an EMPTY store and refuses to leave a bad import
unreported.reverse-state rebuilds a MoM-shaped state (protocol rows, the progress token under
:gm, the :idempotency map from the receipts) from the store.fetch-export! / push-state! / require-frozen-and-drained! are the thin MoM calls.
Anything that writes demands that MoM report the game frozen and drained first.The tools a cutover needs (mom/docs/phase-2/02-state-migration.md): prove that an import from MoM is faithful, and write the GM's state back into MoM's shape so the routed path stays a real fallback after the GM has taken writes. - `diff-state` compares a MoM export with what is in the store. It is for checking an IMPORT: protocol rows must match exactly, so run it before the GM takes writes. - `import-checked!` imports into an EMPTY store and refuses to leave a bad import unreported. - `reverse-state` rebuilds a MoM-shaped state (protocol rows, the progress token under :gm, the :idempotency map from the receipts) from the store. - `fetch-export!` / `push-state!` / `require-frozen-and-drained!` are the thin MoM calls. Anything that writes demands that MoM report the game frozen and drained first.
Runs the transactional outbox (runtime v2).
An event's :effects are written to the store's outbox in the same transaction as
its state (see game.gm.store). This namespace executes them afterwards, and applies
their :on-success / :on-failure callbacks, with these guarantees:
:failed. Anything that does not tell us what happened (401/403, 408, 429, 5xx,
a timeout, a dropped connection) is UNCERTAIN: the effect stays :pending, is
retried with capped backoff, never failed automatically, and logs a loud
EFFECT_STUCK once it is old enough that a person should look.dispatch-fn, and the store marks the callback applied in the same
transaction as the state change they make.DELIVERY. An executor is a fn (effect) -> outcome, or {:run f :delivery d}. The
default, :at-least-once, is right for anything idempotent (a ledger batch under its
key, a PUT, a read): uncertain outcomes retry. :at-most-once is for calls that must
not be repeated blindly (they cost a credit, publish something live): an uncertain
outcome becomes a definite :failed with error :unknown-outcome, unless the
executor says nothing was applied ({:retry true :safe true}, e.g. connection
refused), and an effect whose earlier claim was abandoned (the process died mid-call;
see :abandoned? on claim-effects!) is failed the same way WITHOUT calling the
provider again.
CALLBACK HOOK. Callback events are stored when the effect is emitted, before its
outcome exists. :callback-event-fn (effect event) -> event runs when the callback
is applied, with the completed row, so a GM can put the outcome into the event's data.
Times are epoch milliseconds from now-fn, so everything here is testable without
sleeping.
Runs the transactional outbox (runtime v2).
An event's `:effects` are written to the store's outbox in the same transaction as
its state (see game.gm.store). This namespace executes them afterwards, and applies
their `:on-success` / `:on-failure` callbacks, with these guarantees:
- AT-LEAST-ONCE EXECUTION, EXACTLY-ONCE IN EFFECT. A crash at any point leaves the
row pending; the next sweep re-runs it, and MoM's ledger answers a repeated
idempotency key with the original transaction instead of a second one.
- OUTCOMES ARE DEFINITE OR THEY ARE NOT. MoM saying no (bad request, unknown
currency or player, insufficient balance, key reused with another payload) is
`:failed`. Anything that does not tell us what happened (401/403, 408, 429, 5xx,
a timeout, a dropped connection) is UNCERTAIN: the effect stays `:pending`, is
retried with capped backoff, never failed automatically, and logs a loud
EFFECT_STUCK once it is old enough that a person should look.
- CALLBACKS RUN EXACTLY ONCE. They are dispatched locally (no HTTP hop) through an
injected `dispatch-fn`, and the store marks the callback applied in the same
transaction as the state change they make.
DELIVERY. An executor is a fn `(effect) -> outcome`, or `{:run f :delivery d}`. The
default, `:at-least-once`, is right for anything idempotent (a ledger batch under its
key, a PUT, a read): uncertain outcomes retry. `:at-most-once` is for calls that must
not be repeated blindly (they cost a credit, publish something live): an uncertain
outcome becomes a definite `:failed` with error `:unknown-outcome`, unless the
executor says nothing was applied (`{:retry true :safe true}`, e.g. connection
refused), and an effect whose earlier claim was abandoned (the process died mid-call;
see `:abandoned?` on claim-effects!) is failed the same way WITHOUT calling the
provider again.
CALLBACK HOOK. Callback events are stored when the effect is emitted, before its
outcome exists. `:callback-event-fn` `(effect event) -> event` runs when the callback
is applied, with the completed row, so a GM can put the outcome into the event's data.
Times are epoch milliseconds from `now-fn`, so everything here is testable without
sleeping.gm-lib runtime v2: the direct event endpoint a GM serves itself.
An event goes
seat token -> idempotency receipt -> unchanged dispatch
-> ONE transaction (state + receipt + journal + outbox rows)
-> the event's effects run (ledger calls to MoM) -> response
with no MoM routing. The handler contract is unchanged: a handler is given the whole
state and returns :updated-state plus :effects; only what runs underneath it
changed (see game.gm.store, game.gm.outbox, game.gm.seat-token).
WHO IS CALLING comes only from the verified seat token and MoM's seat lookup, never
from the request body: a :caller, :game-state or :player/name in the body is
ignored. STATE comes only from the store.
WHAT A SEAT MAY SEND is limited here, because MoM no longer sits in front of the
event. Legacy protocols read the acting player and the caller's roles from
event/data and some check no caller, so for a seat caller (never for the GM's own
service-event!): bind-actor refuses an event naming another player and replaces
any roles with the seat's real ones, and service-only-event? refuses the event
types only the GM's own code may send (withdrawal/* by default). Always on: this
is MoM's bind-actor and service-only-event?, without the observe mode.
A FAILED HANDLER RESULT PERSISTS NOTHING. MoM's router saves a failing handler's
:updated-state best-effort; v2 treats a failure as "nothing happened" and records
only a journal entry.
A QUERY (a result with no progress token: dispatch stamps one on every non-query event) writes no journal entry and no receipt.
AN EFFECT THAT IS STILL :pending in the response is not a failure: MoM's answer was
uncertain and the outbox sweeper owns it until the ledger says yes or no.
gm-lib runtime v2: the direct event endpoint a GM serves itself.
An event goes
seat token -> idempotency receipt -> unchanged dispatch
-> ONE transaction (state + receipt + journal + outbox rows)
-> the event's effects run (ledger calls to MoM) -> response
with no MoM routing. The handler contract is unchanged: a handler is given the whole
state and returns `:updated-state` plus `:effects`; only what runs underneath it
changed (see game.gm.store, game.gm.outbox, game.gm.seat-token).
WHO IS CALLING comes only from the verified seat token and MoM's seat lookup, never
from the request body: a `:caller`, `:game-state` or `:player/name` in the body is
ignored. STATE comes only from the store.
WHAT A SEAT MAY SEND is limited here, because MoM no longer sits in front of the
event. Legacy protocols read the acting player and the caller's roles from
`event/data` and some check no caller, so for a seat caller (never for the GM's own
`service-event!`): `bind-actor` refuses an event naming another player and replaces
any `roles` with the seat's real ones, and `service-only-event?` refuses the event
types only the GM's own code may send (`withdrawal/*` by default). Always on: this
is MoM's `bind-actor` and `service-only-event?`, without the observe mode.
A FAILED HANDLER RESULT PERSISTS NOTHING. MoM's router saves a failing handler's
`:updated-state` best-effort; v2 treats a failure as "nothing happened" and records
only a journal entry.
A QUERY (a result with no progress token: dispatch stamps one on every non-query
event) writes no journal entry and no receipt.
AN EFFECT THAT IS STILL `:pending` in the response is not a failure: MoM's answer was
uncertain and the outbox sweeper owns it until the ledger says yes or no.Authenticates a MoM-issued seat token inside the GM, with no per-event round trip for the common case.
Two things are checked, and they come from different places on purpose:
default-seat-ttl-ms, which bounds how stale a revocation can be.Everything fails CLOSED: if the keys or the seat cannot be obtained, the caller is refused with 503, never admitted on a guess. The result is MoM's own :caller envelope, the shape game.gm.authorization already reads.
Authenticates a MoM-issued seat token inside the GM, with no per-event round trip for the common case. Two things are checked, and they come from different places on purpose: 1. WHICH SEAT. The token is an RS256 JWT signed with a private key only MoM holds. The GM verifies it against MoM's published public keys (/.well-known/jwks.json), so it can verify a token but never mint one. The JDK does the crypto; there is no JWT library here. 2. WHAT THE SEAT MAY DO. The token carries no roles, so a role granted or revoked after minting would otherwise be invisible. The GM asks MoM for the seat (GET /admin/games/:game-name/seats/:membership-id) and caches the answer for `default-seat-ttl-ms`, which bounds how stale a revocation can be. Everything fails CLOSED: if the keys or the seat cannot be obtained, the caller is refused with 503, never admitted on a guess. The result is MoM's own :caller envelope, the shape game.gm.authorization already reads.
A GM's own state store (runtime v2).
Today MoM keeps one blob per game and every event rewrites it, so every write
conflicts with every other. Here the GM owns its state as ONE ROW PER PROTOCOL
(:market, :coseller, :gm, ...), so events on different protocols never touch
the same row. The handler contract is unchanged: a handler is given the whole
state and returns a shallow :updated-state keyed by protocol, which replaces
those protocols' rows wholesale, exactly as MoM merges it today.
Three things live beside the state, all written in the SAME transaction as it:
dispatch stamps a fresh one into [:gm :progress-token]
on every non-query event; written into the :gm row that would make every event
rewrite one row. It is stored on the journal entry instead and read back as the
latest one, so :gm is only written when a handler really changes it.WHAT A HANDLER SEES. State is stored as JSON and read back with keyword keys, the
same round trip it makes over HTTP today (keywords become strings, then
:state-enums normalises the ones that matter), so moving a GM onto the store
does not change what its handlers receive. json-normalize is that round trip.
A GM's own state store (runtime v2). Today MoM keeps one blob per game and every event rewrites it, so every write conflicts with every other. Here the GM owns its state as ONE ROW PER PROTOCOL (`:market`, `:coseller`, `:gm`, ...), so events on different protocols never touch the same row. The handler contract is unchanged: a handler is given the whole state and returns a shallow `:updated-state` keyed by protocol, which replaces those protocols' rows wholesale, exactly as MoM merges it today. Three things live beside the state, all written in the SAME transaction as it: - IDEMPOTENCY RECEIPTS, a table of their own instead of a map inside the state, so they neither grow a hot row forever nor make every event touch it. - a JOURNAL: what happened (event, caller, GM version, outcome). It is not authoritative; the state is. It can be redacted for a privacy request. - the PROGRESS TOKEN. `dispatch` stamps a fresh one into `[:gm :progress-token]` on every non-query event; written into the `:gm` row that would make every event rewrite one row. It is stored on the journal entry instead and read back as the latest one, so `:gm` is only written when a handler really changes it. WHAT A HANDLER SEES. State is stored as JSON and read back with keyword keys, the same round trip it makes over HTTP today (keywords become strings, then `:state-enums` normalises the ones that matter), so moving a GM onto the store does not change what its handlers receive. `json-normalize` is that round trip.
In-memory GameStore, for tests and local development. It holds one lock for the whole store, so events are strictly serial and never need a retry; it normalises every value through JSON on the way in, so a handler sees here exactly what it would see from Postgres.
In-memory GameStore, for tests and local development. It holds one lock for the whole store, so events are strictly serial and never need a retry; it normalises every value through JSON on the way in, so a handler sees here exactly what it would see from Postgres.
Postgres GameStore: one schema per game, one row per protocol.
Opt-in. next.jdbc, the PostgreSQL driver and HikariCP are :provided dependencies of gm-lib, so a GM that wants this adds them to its own project.clj; the GMs that do not never load this namespace.
CONCURRENCY. An event row-locks the protocol rows it names (in name order, so two
events cannot deadlock on them), then reads every row. f runs inside that
transaction. A protocol it patches that it did NOT lock is written with a version
check; if another event got there first the transaction rolls back and the event is
re-run on fresh state. A deadlock or serialization failure is retried the same way,
so f must be safe to run again. Events on different protocols never wait on each
other, which is the point of one row per protocol.
f holding a row lock while it runs means a slow handler (an LLM call, say) holds
its protocol's row for that long; other protocols are unaffected.
Postgres GameStore: one schema per game, one row per protocol. Opt-in. next.jdbc, the PostgreSQL driver and HikariCP are :provided dependencies of gm-lib, so a GM that wants this adds them to its own project.clj; the GMs that do not never load this namespace. CONCURRENCY. An event row-locks the protocol rows it names (in name order, so two events cannot deadlock on them), then reads every row. `f` runs inside that transaction. A protocol it patches that it did NOT lock is written with a version check; if another event got there first the transaction rolls back and the event is re-run on fresh state. A deadlock or serialization failure is retried the same way, so `f` must be safe to run again. Events on different protocols never wait on each other, which is the point of one row per protocol. `f` holding a row lock while it runs means a slow handler (an LLM call, say) holds its protocol's row for that long; other protocols are unaffected.
Runs a game on gm-lib runtime v2 (GM_RUNTIME=v2): the GM owns its state in its own Postgres schema and serves events itself on POST /v2/events, instead of MoM routing them in. Default off; with the flag off nothing here is reachable except as a 404.
This is the app-integration layer over game.gm.runtime-v2. A game adds it with:
(v2/with-runtime cfg {:dispatch-fn .. :initial-state .. ...}) on its start-gm! config(v2/wrap-v2 handler) inside its JSON body middlewarev2/service-event! / v2/read-game-state where it used MoM for its own events/statev2/ui-event! for the UI's /events route
See docs/runtime-v2-wiring.md.ONE WRITER. With the flag on the GM's store is the only writer, so the legacy webhook (/v1/gm/handle) answers 410.
Runs a game on gm-lib runtime v2 (GM_RUNTIME=v2): the GM owns its state in its own
Postgres schema and serves events itself on POST /v2/events, instead of MoM routing them
in. Default off; with the flag off nothing here is reachable except as a 404.
This is the app-integration layer over `game.gm.runtime-v2`. A game adds it with:
1. `(v2/with-runtime cfg {:dispatch-fn .. :initial-state .. ...})` on its start-gm! config
2. `(v2/wrap-v2 handler)` inside its JSON body middleware
3. `v2/service-event!` / `v2/read-game-state` where it used MoM for its own events/state
4. `v2/ui-event!` for the UI's /events route
See docs/runtime-v2-wiring.md.
ONE WRITER. With the flag on the GM's store is the only writer, so the legacy webhook
(/v1/gm/handle) answers 410.Avatar provider calls on runtime v2: outbox effects, never work done inside the store transaction.
On the routed runtime handlers/execute-avatar-effects makes the Yvatar HTTP calls
inline while the handler's result is still in hand. On v2 the handler runs inside a
store transaction (a row lock and a database connection), and a portrait render can
take tens of seconds, so the call has to happen after the commit. This namespace does
the three things that takes:
normalize-effect rewrites an avatar effect, which carries top-level fields and
bare-keyword callbacks, into the outbox's shape (:data, vector callbacks).executors runs each op against the configured provider and classifies the answer.callback-event-fn puts the outcome into the avatar/succeeded|failed callback,
which used to be built from the provider result in hand.DELIVERY. Reads and upsert are idempotent: an uncertain answer is retried. The rest
are not (a portrait costs a credit; publishing posts something live; a second accept
fails although the first applied), so they are AT-MOST-ONCE: an uncertain answer
becomes a definite unknown-outcome failure that marks the player's mirror :error,
and a call interrupted by a crash is not made again. A connection that was never
made (refused, unknown host, connect timeout) and a 429 applied nothing, so those
still retry. See game.gm.outbox.
Avatar provider calls on runtime v2: outbox effects, never work done inside the store transaction. On the routed runtime `handlers/execute-avatar-effects` makes the Yvatar HTTP calls inline while the handler's result is still in hand. On v2 the handler runs inside a store transaction (a row lock and a database connection), and a portrait render can take tens of seconds, so the call has to happen after the commit. This namespace does the three things that takes: - `normalize-effect` rewrites an avatar effect, which carries top-level fields and bare-keyword callbacks, into the outbox's shape (`:data`, vector callbacks). - `executors` runs each op against the configured provider and classifies the answer. - `callback-event-fn` puts the outcome into the `avatar/succeeded|failed` callback, which used to be built from the provider result in hand. DELIVERY. Reads and `upsert` are idempotent: an uncertain answer is retried. The rest are not (a portrait costs a credit; publishing posts something live; a second accept fails although the first applied), so they are AT-MOST-ONCE: an uncertain answer becomes a definite `unknown-outcome` failure that marks the player's mirror `:error`, and a call interrupted by a crash is not made again. A connection that was never made (refused, unknown host, connect timeout) and a 429 applied nothing, so those still retry. See game.gm.outbox.
Operator CLI for moving a game's state between MoM and the GM's own store (mom/docs/phase-2/02-state-migration.md). Run from the repo root with the GM's usual environment: GAME_NAME, MOM_URL, the MOM_* credentials, and GM_PG_URL/USER/PASSWORD (GM_PG_SCHEMA to aim at a scratch schema for a dry run).
lein run -m <game>.migrate fetch FILE save MoM's export (read-only) lein run -m <game>.migrate diff FILE compare a saved export with the store (read-only; exit 1 if they differ) lein run -m <game>.migrate import FILE --confirm import into the EMPTY store and verify lein run -m <game>.migrate rollback-write --confirm write the store's state back into MoM
import and rollback-write write, so each refuses unless MoM reports the game frozen
with no events in flight (POST /admin/games/:g/freeze, then wait for in-flight 0), and
unless --confirm is given. import also refuses a store that already has state. Never boot
the GM with GM_RUNTIME=v2 against a schema before importing into it: it seeds an empty
store from the game spec, and the import would then be refused.
Operator CLI for moving a game's state between MoM and the GM's own store
(mom/docs/phase-2/02-state-migration.md). Run from the repo root with the GM's usual
environment: GAME_NAME, MOM_URL, the MOM_* credentials, and GM_PG_URL/USER/PASSWORD
(GM_PG_SCHEMA to aim at a scratch schema for a dry run).
lein run -m <game>.migrate fetch FILE save MoM's export (read-only)
lein run -m <game>.migrate diff FILE compare a saved export with the store
(read-only; exit 1 if they differ)
lein run -m <game>.migrate import FILE --confirm import into the EMPTY store and verify
lein run -m <game>.migrate rollback-write --confirm
write the store's state back into MoM
`import` and `rollback-write` write, so each refuses unless MoM reports the game frozen
with no events in flight (POST /admin/games/:g/freeze, then wait for in-flight 0), and
unless --confirm is given. `import` also refuses a store that already has state. Never boot
the GM with GM_RUNTIME=v2 against a schema before importing into it: it seeds an empty
store from the game spec, and the import would then be refused.Anonymous visitors: the web layer of coseller attribution for readers who have no seat yet.
A reader arriving through a ?ref= link cannot use /events — that route is
seat-gated, and event-payload rewrites the actor to the seat. The GM mints
them a visitor:<uuid> instead and records real touches against it; when
they later sign in, coseller/claim-visitor re-keys those touches onto their
player-name. See game.protocols.coseller for why the prefix is load-bearing.
Deliberately NOT under game.protocols, for the same reason as
game.gm.identity: everything there is a pure event handler with a manifest,
dispatched from MoM through /v1/gm/handle. This is the opposite shape — HTTP
routes served to a browser, proxying MoM, holding no game state and
advertising no handlers. The protocol half of anonymous visitors is already
in game.protocols.coseller and stays there.
A consuming GM supplies three things:
the routes themselves — gm-lib carries no compojure dependency, so each game keeps its own route definitions and delegates here:
(POST "/public/touch" [:as request] (gm-visitor/record-visitor-touch request)) (POST "/coseller/claim-visitor" [:as request] (let [{:keys [context seat response]} (require-selected-seat request)] (or response (gm-visitor/claim-visitor-touches request (:game-name context) seat {:submit-ui-event-fn submit-ui-event}))))
ring.middleware.cookies/wrap-cookies in the app's middleware stack.
Without it :cookies is absent on the request and unserialised on the
response, so every visit mints a new id and nothing is ever claimable
— a silent failure with a 200 on every call. This is the one wiring
mistake that looks like it works.
the dispatch fn for the claim (:submit-ui-event-fn), because the claim
must run as the signed-in caller, and building that payload is
game-specific. The public touch needs no injection: it runs on service
credentials via game.gm.identity/submit-service-event, and the game
name comes from the runtime.
Anonymous visitors: the web layer of coseller attribution for readers who
have no seat yet.
A reader arriving through a `?ref=` link cannot use /events — that route is
seat-gated, and event-payload rewrites the actor to the seat. The GM mints
them a `visitor:<uuid>` instead and records real touches against it; when
they later sign in, `coseller/claim-visitor` re-keys those touches onto their
player-name. See `game.protocols.coseller` for why the prefix is load-bearing.
Deliberately NOT under `game.protocols`, for the same reason as
`game.gm.identity`: everything there is a pure event handler with a manifest,
dispatched from MoM through /v1/gm/handle. This is the opposite shape — HTTP
routes served to a *browser*, proxying MoM, holding no game state and
advertising no handlers. The *protocol* half of anonymous visitors is already
in `game.protocols.coseller` and stays there.
A consuming GM supplies three things:
1. the routes themselves — gm-lib carries no compojure dependency, so each
game keeps its own route definitions and delegates here:
(POST "/public/touch" [:as request]
(gm-visitor/record-visitor-touch request))
(POST "/coseller/claim-visitor" [:as request]
(let [{:keys [context seat response]} (require-selected-seat request)]
(or response
(gm-visitor/claim-visitor-touches
request (:game-name context) seat
{:submit-ui-event-fn submit-ui-event}))))
2. `ring.middleware.cookies/wrap-cookies` in the app's middleware stack.
Without it `:cookies` is absent on the request and unserialised on the
response, so every visit mints a *new* id and nothing is ever claimable
— a silent failure with a 200 on every call. This is the one wiring
mistake that looks like it works.
3. the dispatch fn for the claim (`:submit-ui-event-fn`), because the claim
must run as the signed-in caller, and building that payload is
game-specific. The public touch needs no injection: it runs on service
credentials via `game.gm.identity/submit-service-event`, and the game
name comes from the runtime.Avatar Protocol — each player owns and drives a rich avatar persona (persona fields, a generated portrait, and a public profile) as a first-class game capability.
The protocol is PROVIDER-AGNOSTIC. It owns the :avatar game-state and the
event contract, but never talks to an avatar service directly. A command
validates, records the player's local mirror as :pending, and emits a
provider-neutral :avatar/* effect. The GM-side interceptor
(game.handlers/execute-avatar-effects) resolves the configured provider (see
game.protocols.avatar-provider; Yvatar is the first implementation), runs the
call holding the service credentials, and folds the normalized result back into
this protocol via the avatar/succeeded / avatar/failed system callbacks —
the exact analog of coseller's ledger-settlement callbacks.
Player↔avatar mapping is automatic: the interceptor passes provider = game-name, external-user-id = player-name, so each player owns exactly one
avatar per game with no extra input. No provider name appears anywhere in this
namespace or its state — a second provider needs zero changes here.
Avatar Protocol — each player owns and drives a rich avatar persona (persona fields, a generated portrait, and a public profile) as a first-class game capability. The protocol is PROVIDER-AGNOSTIC. It owns the `:avatar` game-state and the event contract, but never talks to an avatar service directly. A command validates, records the player's local mirror as `:pending`, and emits a provider-neutral `:avatar/*` effect. The GM-side interceptor (`game.handlers/execute-avatar-effects`) resolves the configured provider (see `game.protocols.avatar-provider`; Yvatar is the first implementation), runs the call holding the service credentials, and folds the normalized result back into this protocol via the `avatar/succeeded` / `avatar/failed` system callbacks — the exact analog of coseller's ledger-settlement callbacks. Player↔avatar mapping is automatic: the interceptor passes `provider = game-name`, `external-user-id = player-name`, so each player owns exactly one avatar per game with no extra input. No provider name appears anywhere in this namespace or its state — a second provider needs zero changes here.
Provider seam for the Avatar Protocol.
The Avatar Protocol (game.protocols.avatar) is provider-agnostic: it owns the
game-state and the event contract, but never talks to an avatar service
directly. Every outbound call goes through this AvatarProvider seam, and the
GM-side interceptor (game.handlers) resolves the configured implementation and
runs it (holding the service credentials — the withdrawal/Stripe "key locality"
pattern). Yvatar is the first implementation
(game.protocols.providers.yvatar); adding another provider later means a new
defrecord + a factory case, and nothing in the protocol layer changes.
Every method takes a provider-neutral ctx:
{:provider <string> ; e.g. the game-name — maps to Yvatar external_provider
:external-user-id <string>} ; e.g. the player-name — maps to Yvatar external_user_id
Every method returns ONE of:
{:ok? true :avatar {:avatar-id :public-handle :portrait-url
:pending-portrait-url :public-url :name :status
;; draft-post / regenerate-post / fetch-post-status
;; also return:
:post-id :post-status :prompt :caption :preview-url
;; confirm-publish also returns:
:platform :post-url :platform-post-id ...}}
{:ok? false :status <int|keyword> :error <any>}
The :avatar map is already provider-neutral (kebab-case keys); the protocol
layer and interceptor only ever see this shape, never provider-specific JSON.
Provider seam for the Avatar Protocol.
The Avatar Protocol (`game.protocols.avatar`) is provider-agnostic: it owns the
game-state and the event contract, but never talks to an avatar service
directly. Every outbound call goes through this `AvatarProvider` seam, and the
GM-side interceptor (`game.handlers`) resolves the configured implementation and
runs it (holding the service credentials — the withdrawal/Stripe "key locality"
pattern). Yvatar is the first implementation
(`game.protocols.providers.yvatar`); adding another provider later means a new
`defrecord` + a factory case, and nothing in the protocol layer changes.
## Context map
Every method takes a provider-neutral `ctx`:
{:provider <string> ; e.g. the game-name — maps to Yvatar external_provider
:external-user-id <string>} ; e.g. the player-name — maps to Yvatar external_user_id
## Normalized result
Every method returns ONE of:
{:ok? true :avatar {:avatar-id :public-handle :portrait-url
:pending-portrait-url :public-url :name :status
;; draft-post / regenerate-post / fetch-post-status
;; also return:
:post-id :post-status :prompt :caption :preview-url
;; confirm-publish also returns:
:platform :post-url :platform-post-id ...}}
{:ok? false :status <int|keyword> :error <any>}
The `:avatar` map is already provider-neutral (kebab-case keys); the protocol
layer and interceptor only ever see this shape, never provider-specific JSON.Coseller Protocol — a referral economy layered on top of Market.
Authorized players (:coseller role) share trackable URLs to game assets
(a Market capability, or an external website/app). A buyer's clicks become
touches. When the GM settles a paid checkout, a GM-configured portion of
each order's existing Market commission becomes a coseller pool, which is
distributed across the buyer's chain-of-influence via a pluggable attribution
algorithm (see game.protocols.coseller-attribution).
Coseller is a pure distribution layer: it never changes the buyer's price or
the seller's net. It reads Market orders/checkouts from the shared game-state
and writes only its own :coseller namespace. Settlement is a manual,
idempotent GM command — no auto-fire on payment.
Coseller Protocol — a referral economy layered on top of Market. Authorized players (`:coseller` role) share trackable URLs to game assets (a Market capability, or an external website/app). A buyer's clicks become `touches`. When the GM settles a paid checkout, a GM-configured *portion* of each order's existing Market commission becomes a `coseller pool`, which is distributed across the buyer's chain-of-influence via a pluggable attribution algorithm (see `game.protocols.coseller-attribution`). Coseller is a pure distribution layer: it never changes the buyer's price or the seller's net. It reads Market orders/checkouts from the shared game-state and writes only its own `:coseller` namespace. Settlement is a manual, idempotent GM command — no auto-fire on payment.
Pluggable chain-of-influence attribution for the Coseller Protocol.
weigh-chain is a multimethod dispatched on the algorithm name (a string).
It turns a buyer's touches into an ordered chain of cosellers with relative
weights. allocate then distributes an integer pool across those weights
using the largest-remainder method so the splits always sum exactly to the
pool — no rounding drift, no minted or lost units.
Adding a new attribution model = one new defmethod weigh-chain plus a new
value the GM may set as :algorithm on the policy. Nothing else changes.
Pluggable chain-of-influence attribution for the Coseller Protocol. `weigh-chain` is a multimethod dispatched on the algorithm name (a string). It turns a buyer's touches into an ordered chain of cosellers with relative weights. `allocate` then distributes an integer pool across those weights using the largest-remainder method so the splits always sum exactly to the pool — no rounding drift, no minted or lost units. Adding a new attribution model = one new `defmethod weigh-chain` plus a new value the GM may set as `:algorithm` on the policy. Nothing else changes.
Public discovery from a trusted GM adapter; no I/O or model calls. :discoverability/context is supplied by the GM, never by event/data.
Public discovery from a trusted GM adapter; no I/O or model calls. :discoverability/context is supplied by the GM, never by event/data.
Mandate Protocol: explicit, bounded authority for one Player to represent another. Release 1 implements the request side only. A request is an invitation to evaluate representation; it grants no authority.
Mandate Protocol: explicit, bounded authority for one Player to represent another. Release 1 implements the request side only. A request is an invitation to evaluate representation; it grants no authority.
The Market authority bridge: the seam where a Mandate becomes commercially load-bearing.
Market assumes a seller owns what they sell. Mandate answers the earlier question — why is this representative allowed to act for this principal, for this subject, within these limits? This namespace joins them at the moment a representative acts: a CLAIM in the event data, a VERIFICATION against live Mandate state in the same transition, and a durable RECORD of the verdict.
It lives apart from market.clj so that a 6,000-line namespace running in
production gains only a require and four small call sites.
Two things it deliberately is not:
Market writes only {:market ...}; nothing here ever writes Mandate state.
The Market authority bridge: the seam where a Mandate becomes commercially
load-bearing.
Market assumes a seller owns what they sell. Mandate answers the earlier
question — why is this representative allowed to act for this principal, for
this subject, within these limits? This namespace joins them at the moment a
representative acts: a CLAIM in the event data, a VERIFICATION against live
Mandate state in the same transition, and a durable RECORD of the verdict.
It lives apart from `market.clj` so that a 6,000-line namespace running in
production gains only a require and four small call sites.
Two things it deliberately is not:
- An explicit Mandate claim requires a canonical MoM caller matching the
Market actor. Unclaimed self-owned Market paths still need their own
caller and inventory ownership policy before a Game exposes them.
- It does NOT route money. A represented sale still pays the representative.
Market writes only `{:market ...}`; nothing here ever writes Mandate state.Authenticates Woo quotes recorded through MoM's legacy action route.
Authenticates Woo quotes recorded through MoM's legacy action route.
Yvatar implementation of the AvatarProvider seam.
This is the ONLY place that knows the Yvatar Developer Platform API — its
endpoints, its snake_case JSON, and its error codes. It maps the protocol's
provider-neutral ctx/persona to Yvatar's external_provider /
external_user_id mapping and back to the normalized result documented in
game.protocols.avatar-provider.
Endpoints (all under /api/v1/platform/users/:external-user-id/yvatar): PUT . upsert persona GET . fetch full persona POST ./portrait generate a pending portrait POST ./portrait/accept promote pending -> accepted GET ./public embed-safe public shape POST ./content start a video post (async — returns generating_video) GET ./content check the pending post's status (on-demand, not a poll loop) POST ./content/regenerate redraft the current pending post (async) POST ./content/publish publish the current draft post to a platform
The ./content* endpoints are video-only — Yvatar's Platform API does not support image posts (the content studio's session-authenticated UI does, but that surface is not exposed here). Video generation is asynchronous: POST ./content and POST ./content/regenerate return immediately with status "generating_video"; GET ./content is how a caller checks whether it finished, intended for a caller-side "refresh" action rather than a background polling loop.
Auth: Authorization: Bearer <yvtr_ token> (developer-owned token).
Yvatar implementation of the `AvatarProvider` seam. This is the ONLY place that knows the Yvatar Developer Platform API — its endpoints, its snake_case JSON, and its error codes. It maps the protocol's provider-neutral `ctx`/persona to Yvatar's `external_provider` / `external_user_id` mapping and back to the normalized result documented in `game.protocols.avatar-provider`. Endpoints (all under /api/v1/platform/users/:external-user-id/yvatar): PUT . upsert persona GET . fetch full persona POST ./portrait generate a pending portrait POST ./portrait/accept promote pending -> accepted GET ./public embed-safe public shape POST ./content start a video post (async — returns generating_video) GET ./content check the pending post's status (on-demand, not a poll loop) POST ./content/regenerate redraft the current pending post (async) POST ./content/publish publish the current draft post to a platform The ./content* endpoints are video-only — Yvatar's Platform API does not support image posts (the content studio's session-authenticated UI does, but that surface is not exposed here). Video generation is asynchronous: POST ./content and POST ./content/regenerate return immediately with status "generating_video"; GET ./content is how a caller checks whether it finished, intended for a caller-side "refresh" action rather than a background polling loop. Auth: `Authorization: Bearer <yvtr_ token>` (developer-owned token).
Reusable discovery and response scope for request-shaped protocols.
Reusable discovery and response scope for request-shaped protocols.
Withdrawal Protocol — lets a player move their shadow-ledger balance out of the game to their own bank via Stripe Connect.
The GM never talks to Stripe; it only produces the atomic ledger moves as MoM
ledger/batch effects (so they run under the game's GM authority, never a
direct ledger HTTP call). The actual Stripe Transfer is performed by the game's
HTTP layer (game.payments) between the reserve and settle steps:
request -> ledger/batch player -> @withdrawals-pending (game.payments calls Stripe create-transfer) settle -> ledger/batch @withdrawals-pending -> @withdrawals-paid (success) reverse -> ledger/batch @withdrawals-pending -> player (failure)
A single withdrawal-id (minted by game.payments) keys the reserve ledger
move and the Stripe Idempotency-Key; settle/reverse derive :settle/:reverse
suffixed keys so replays are exactly-once. Records live in game-state under
:withdrawal :records and are advanced by MoM's on-success callbacks.
Withdrawal Protocol — lets a player move their shadow-ledger balance out of the game to their own bank via Stripe Connect. The GM never talks to Stripe; it only produces the atomic ledger moves as MoM `ledger/batch` effects (so they run under the game's GM authority, never a direct ledger HTTP call). The actual Stripe Transfer is performed by the game's HTTP layer (game.payments) between the reserve and settle steps: request -> ledger/batch player -> @withdrawals-pending (game.payments calls Stripe create-transfer) settle -> ledger/batch @withdrawals-pending -> @withdrawals-paid (success) reverse -> ledger/batch @withdrawals-pending -> player (failure) A single `withdrawal-id` (minted by game.payments) keys the reserve ledger move and the Stripe Idempotency-Key; settle/reverse derive `:settle`/`:reverse` suffixed keys so replays are exactly-once. Records live in game-state under `:withdrawal :records` and are advanced by MoM's on-success callbacks.
The subset of JSON Schema gm-lib actually enforces.
Protocols let a game publish a schema so agents can discover what a payload must contain. Publishing is easy; enforcing is not, and gm-lib carries no JSON Schema engine. Rather than pretend, this namespace implements a small, flat subset and is explicit about the boundary:
type object, string, number, integer, boolean, array
required list of property names that must be present
additionalProperties false rejects anything not named in properties
properties one level deep, primitive type only
Anything else in a schema is IGNORED, not rejected. A game may publish a richer schema than this — nested objects, enums, patterns, ranges — and it will still be served to agents verbatim through introspection. It simply will not be checked here, and a game that needs those rules enforced must check them itself. Silently ignoring is the honest choice: rejecting a schema we merely cannot check would stop games describing themselves fully, and pretending to validate would be worse than both.
Returns nil when valid, otherwise ONE issue map shaped like the protocol validators' vocabulary — {:field :issue :expected :received} — so callers can fold it into an existing error envelope without translation.
The subset of JSON Schema gm-lib actually enforces.
Protocols let a game publish a schema so agents can discover what a payload
must contain. Publishing is easy; enforcing is not, and gm-lib carries no
JSON Schema engine. Rather than pretend, this namespace implements a small,
flat subset and is explicit about the boundary:
type object, string, number, integer, boolean, array
required list of property names that must be present
additionalProperties false rejects anything not named in properties
properties one level deep, primitive `type` only
Anything else in a schema is IGNORED, not rejected. A game may publish a
richer schema than this — nested objects, enums, patterns, ranges — and it
will still be served to agents verbatim through introspection. It simply
will not be checked here, and a game that needs those rules enforced must
check them itself. Silently ignoring is the honest choice: rejecting a
schema we merely cannot check would stop games describing themselves fully,
and pretending to validate would be worse than both.
Returns nil when valid, otherwise ONE issue map shaped like the protocol
validators' vocabulary — {:field :issue :expected :received} — so callers
can fold it into an existing error envelope without translation.URL slugs: turning a human title into an address.
Pure and dependency-free so anything can use it. Nothing in the repo did this before — market taxonomy slugs (game.protocols.market) are caller-supplied strings, validated as non-empty and never normalized, which is why a category can be registered as "Merch " and a post could not have a slug at all.
URL slugs: turning a human title into an address. Pure and dependency-free so anything can use it. Nothing in the repo did this before — market taxonomy slugs (game.protocols.market) are caller-supplied strings, validated as non-empty and never normalized, which is why a category can be registered as "Merch " and a post could not have a slug at all.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |