Skip to main content

FigNet.Persistence - Architecture Overview

This document is the on-ramp for a developer who has never seen the codebase. Read it once and you should know what runs where, what talks to what, and where to look next.

It is the top of the documentation tree for the BaaS (Backend-as-a-Service) layer.

1. What this is

FigNet.Persistence is a self-hosted, multi-tenant BaaS layer that sits next to FigNet's real-time game servers (EntangleServer for gameplay, FnVoiceServer for voice). It owns the slow, durable, cross-session state that a game wants but doesn't want to keep in-memory in a room process: accounts, profiles, wallets, IAPs, leaderboards, friends, presence, chat, notifications, remote config, analytics, server-side scripts, scheduled jobs.

It is shaped like Talo / Nakama / PlayFab, but is intentionally:

  • Self-hosted. You run it on your own VPS. No external SaaS dependency. The same image runs on Hetzner, DigitalOcean, AWS, or a laptop.
  • Multi-tenant from day one. Every player-facing row carries an app_id. One Persistence instance can serve N games.
  • Opinionated about the data layer. PostgreSQL is the system of record. Redis is the hot-path cache + realtime pub/sub. Object storage (S3-compatible: S3, R2, Spaces, MinIO) is for user-generated content + backups.
  • Source-gen JSON only. Reflection-based JSON is disabled. Every DTO registers in PersistenceJsonContext.cs. This is non-negotiable - it's how the trimmed self-contained binary stays AOT-friendly and fast.
  • No EF Core. Data access is linq2db - a thin LINQ-to-SQL provider with explicit Insert/Update/InsertWithIdentity and no change-tracking magic. See section 10 for the ground-rules.

2. The shipping fleet

The product runs as a fixed set of processes. The minimum cluster is six containers.

ProcessProjectPublic portInternal portPurpose
fignet-persistence-apiFigNet.Persistence.Api8090 (HTTP/1.1, WSS)8091 (h2c)Player REST + WSS; S2S admin endpoints over h2c
fignet-persistence-workerFigNet.Persistence.Worker--Background hosted services (heartbeat, restart listener). Cron/jobs currently run in the API, not the worker - the worker is a placeholder for future stream consumers.
entangleserverEntangleServer8080 (admin HTTP+WS), 9000 (game WSS), 5559 (UDP RUDP)-Game runtime. Hosts the admin dashboard + the PersistenceProxy that S2S-signs into 8091.
postgrespostgres:16-alpine-5432System of record.
redisredis:7-alpine-6379Cache, pub/sub, presence, locks, in-process snapshots.
minio (or external S3)minio/minio9000/9001 dev-UGC content + PG backups + Redis snapshots.

In front of the public traffic is a reverse proxy - Caddy in the reference deployment - that terminates TLS and routes by hostname. The Persistence API does not terminate TLS itself.

How the boxes talk

Browser / Unity SDK ──TLS──> Caddy ──HTTP/1.1──> Persistence.Api :8090 ─┐
──WSS────────> Persistence.Api :8090 ─┤
├─> PG (5432)
Admin dashboard browser ─TLS──> Caddy ──HTTP/1.1──> EntangleServer :8080├─> Redis (6379)
│ ├─> S3/MinIO (9000)
│ h2c │
└────────────> Persistence.Api :8091
(PersistenceProxy + S2S HMAC)

The dashboard never speaks directly to Persistence.Api. Every admin call goes EntangleServer -> :8091. This is deliberate - see section 4.

3. The trust + auth model

There are exactly three credential types on the wire. Mixing them up is the most common cause of "why is this 401?".

3a. Player JWT - Bearer token on /v1/*

Issued by AuthFlow on login. Signed HS256 with PERSIST_JWT_SECRET. Validated by ASP.NET's AddJwtBearer (Program.cs:449). The same secret signs and validates - there is no public-key story here, which is fine because only Persistence issues + Persistence validates.

  • iss = fignet-persistence, aud = fignet (configurable, see Persistence.yaml).
  • sub = account id (canonical primary key - UUID).
  • accessTtl defaults 15 min, refreshTtl 30 days. Refresh tokens live in PG (sessions table) so revocation is a DELETE.
  • For the WSS endpoint /v1/realtime, the token comes through ?token= in the URL (browsers can't set custom headers on the upgrade request). See Program.cs:466-478.

3b. Admin JWT - Bearer token on /v1/admin/*

Issued by AdminAuthEndpoints (login flow, separate from player flow). Same signing key, different audience. Carries role (super / admin / support / readonly) and sub (admin user id from admin_users).

Scopes are derived from role + extra_scopes union, not stored in the JWT body. See AdminScopes.cs. Every admin endpoint declares its required scope; the check happens inside the handler (no global authorization filter).

3c. Server-to-server HMAC - X-FigNet-S2S-* headers on :8091

The proxy hop. EntangleServer's PersistenceProxy signs every request with HMAC-SHA256(timestamp + "." + body) using PERSIST_S2S_SECRET. The header set:

X-FigNet-S2S-Ts: <unix-millis>
X-FigNet-S2S-Sig: <base64 hmac>
X-Admin-Sub: <admin user id> # propagated from admin JWT
X-Admin-Role: <super|admin|support|readonly>
X-Admin-Username: <admin username>
X-Admin-Scopes: <comma-separated extra scopes>

Validation happens in S2sGuard. The timestamp is rejected if more than 5 minutes off (clock-skew tolerance). The body is streamed through IncrementalHash rather than buffered.

Why two hops instead of one? The dashboard already authenticates to EntangleServer (the existing admin surface). Letting EntangleServer mint S2S to Persistence means the admin JWT verification stays in one place + the admin session cookie stays single-origin. The cost is the extra TCP hop - which is why :8091 uses h2c (HTTP/2 cleartext) for multiplexing. See section 4.

4. Two ports / two protocols

The API listens on two ports (Program.cs:80-112).

PortProtocolListenerUsed by
8090HTTP/1.1 + WebSocketHttpProtocols.Http1Player SDK, browser game client. WSS upgrade only works over HTTP/1.1 (RFC 6455).
8091HTTP/2 cleartext (h2c)HttpProtocols.Http2PersistenceProxy only. Never mapped to the host in compose - internal Docker network only.

The h2c port exists because every admin click in the dashboard becomes an S2S call. h2 gives multiplexing (many concurrent admin calls reuse one TCP connection) + HPACK header compression (the five X-Admin-* headers repeat on every call). On the client side (PersistenceProxy.cs), getting h2c to actually engage was a two-part trap:

  1. AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true) must run before the HttpClient static field initializes. This is done via an ordered static field initializer so the textual order is the runtime order.
  2. HttpClient.DefaultRequestVersion is ignored by HttpRequestMessage because the message ctor pre-populates Version = 1.1. Override per-request: req.Version = HttpVersion.Version20; req.VersionPolicy = RequestVersionExact;.

If you're debugging "why is this admin call 400/500", the first thing to check is that the request actually arrived as HTTP/2.

5. The data layer

5a. PostgreSQL

System of record. One database, one schema (public). Schema is defined by forward-only DbUp migrations under FigNet.Persistence.Core/Migrations/. They are embedded resources (.sql files), run in numeric order at API startup (Program.cs:508-519). Failed migrations abort startup - there is no graceful fallback.

Rules:

  • Migrations are forward-only. Never edit a shipped one - write a new one that fixes it.
  • No destructive change on a populated table without an *_archived mirror. See migrations 023 / 025.
  • Every player-facing table has app_id. Composite indexes start with app_id so the query planner can prune by tenant.
  • Analytics tables are PG-declarative-partitioned by day. analytics_events_YYYYMMDD child tables are created daily by AnalyticsPartitionManager and dropped on retention by the archive-cleanup job.

5b. linq2db (not EF)

linq2db is a thin LINQ-to-SQL provider with explicit CRUD. There is no change-tracking, no lazy loading, no migrations from the ORM (that's DbUp's job).

The persistent connection pattern is always:

using var db = _dbFactory.Create(); // PersistenceDbFactory; opens NpgsqlConnection + wraps in DataConnection
// ... .InsertAsync / .UpdateAsync / .DeleteAsync / .ToListAsync

Disposal footgun - any read method that returns a Task without await will dispose the DataConnection before the query runs. Always async + await on linq2db reads.

Nested-transaction footgun - BeginTransaction on a connection that already has an open transaction will throw. Services that might be called inside a parent transaction (e.g. WalletService.DebitAsync called from ShopService.PurchaseAsync) check db.Transaction != null and join rather than open a fresh one. This pattern is standard for any service that participates in a multi-write atomic flow.

5c. Redis

Hot path for everything that isn't a system-of-record write. Configured with an explicit ConfigurationOptions (Program.cs:161-171):

  • AbortOnConnectFail = false - process boots even if Redis is briefly unreachable; cache reads fall through to PG.
  • KeepAlive = 60 - PING every minute so middle-boxes don't drop the connection.
  • ConnectRetry = 3, 5s timeouts.

Surfaces using Redis:

SurfaceUse
CacheCache-aside for accounts, profiles, shop catalog, remote-config bundles. Pre-rendered JSON byte[] to skip a second serialize.
Pubsubcache:invalidate for cross-replica cache busting, rt:user:{id} for realtime push, scripts:{appId}:invalidate for hot-reload.
Presencepresence:{appId}:{accountId} short-TTL keys; PresenceService sets + clears.
SchedulerLocklock:job:{appId}:{name} - distributed at-most-once for the cron tick.
BanBlocklistbans:active SET - fast SISMEMBER check on every authenticated request.
TelemetryIn-process counter snapshot mirror of OTEL meters (so dashboards don't have to scrape Prometheus).
ScriptKvPer-app, per-script KV available to TS scripts via fignet.kv.*.

Every one of those surfaces is wrapped in a per-surface circuit breaker - see RedisBreaker.cs. When the breaker opens, the surface's fail-open / fail-closed policy decides what happens. The dashboard's /ops/redis-breakers page surfaces the live state. Specifically:

  • BanBlocklist, Cache, Presence, Pubsub, Telemetry, ScriptKv are fail-open.
  • SchedulerLock is fail-closed - better to miss a cron tick than double-fire it on every replica.

5d. Object storage (S3-compatible)

Abstracted behind IContentStore. The implementation is S3ContentStore - works against AWS S3, Cloudflare R2, DigitalOcean Spaces, Hetzner Object Storage, MinIO, and Firebase Storage (which exposes an S3 gateway). Only endpoint + keys differ.

Two responsibilities:

  1. UGC - /v1/content/* issues presigned PUT URLs that point at the public endpoint, players upload directly, server records the metadata row in content_items.
  2. Backups - PgBackupService spawns pg_dump | gzip and streams to backups/pg/YYYY/MM/DD/.... PgRestoreService reverses it. The Dockerfile installs postgresql-client-16 for exactly this.

There are two endpoints in the config: Endpoint (server-side, Docker DNS) and PublicEndpoint (the URL embedded into presigned URLs handed to browsers). In prod, these should be the same URL.

6. The HTTP pipeline

Order matters. From Program.cs:612-641:

UseRouting
└─> UseWebSockets
└─> MaintenanceMiddleware (503 if maintenance_mode != off, before any other work)
└─> LoadShedMiddleware (ConcurrencyLimiter, 503 + Retry-After if cap hit)
└─> UseRateLimiter (per-policy fixed-window limiter)
└─> UseAuthentication (JWT validation)
└─> BanCheckMiddleware (SISMEMBER bans:active)
└─> UseAuthorization
└─> endpoint

Maintenance + load-shed sit before auth and rate-limit on purpose: a 503 cycle should cost nothing. Admin / internal / health paths bypass both middleware via BypassPathPrefixes in PersistenceConfig.cs so operators can always reach the dashboard during an incident.

Rate-limit policies are config-driven (OpsEndpoints.ResolveActivePolicies). Adding a new policy is two lines: a row in the config and .RequireRateLimiting("name") on the endpoint group. Operator overrides via /ops/rate-limits are stored in system_flags and applied on next restart.

7. Realtime model

A single WSS endpoint, /v1/realtime, served on port 8090 by RealtimeEndpoint. The SDK opens one connection per logged-in account. The token is passed via ?token=<jwt> (browsers can't set headers on the upgrade).

The wire is JSON envelopes:

{ "kind": "wallet.changed", "topic": "wallet:abc-123", "ts": 1716200000000, "payload": { ... } }

Producers publish via IEventPublisher. There are two flavours:

  • Per-user push - PublishToUserAsync(accountId, envelope) on Redis channel rt:user:{accountId}. The connected SDK receives it.
  • Topic fanout - PublishFanoutAsync(topic, envelope) on Redis channel rt:topic:{topic}. Anyone subscribed to that topic receives it. Subscribe ACL (SubscriptionAcl.cs) enforces that a caller can only subscribe to their own topics + their friends' presence.

Topic-emitting surfaces today: profile.changed, metadata.changed, wallet.changed, leaderboard.changed, friends.*, chat.dm, chat.group.*, presence.*, notification.received, and the moderation system.kick / system.banned.

Worth knowing: Redis pub/sub is at-most-once - if the SDK is offline at the moment of publish, the message is dropped on the floor. Anything that has to survive offline is also persisted (chat DMs to chat_dm_messages, notifications to the inbox).

8. Hooks + server-side scripts

Game teams who can't ship server code register TS/JS scripts that hook into business events.

8a. Hook registry

HookRegistry holds the dispatch table. Two flavours:

  • before.* - runs inside the service transaction. Can mutate the payload (a before.wallet.debit hook can adjust the amount). Can abort by throwing - the parent action rolls back. Used for: validation, business-rule injection, anti-cheat checks.
  • after.* - runs after commit. Cannot mutate. Failures are logged and swallowed. Used for: notifications, side effects, downstream grants.

Hook kinds are enumerated in HookKind.cs.

8b. After-hook fanout to CLR listeners

after.* hooks also fan out to any C# implementation of IHookAfterListener. Today, ProfileTemplateService implements it - that's how event-triggered profile templates ("grant a welcome pack on first purchase") work without a TS script in between. The listener is resolved lazily via IServiceProvider rather than constructor-injected as IEnumerable<IHookAfterListener> - that avoids a DI cycle that previously hung the boot on a 400-cascade.

8c. The JS host

ScriptEngine is a Jint engine pool with a compiled-program cache. Source is TS, compiled to JS at save time, cached in Redis. Hot-reload watches the scripts:{appId}:invalidate channel and evicts on signal.

The runtime API (RuntimeApi/) is the "nakama runtime" - what scripts can call:

fignet.accounts.get(id)
fignet.wallet.debit(accountId, currency, amount, reason)
fignet.wallet.credit(...)
fignet.metadata.read(accountId, namespace, key)
fignet.metadata.write(accountId, namespace, key, value)
fignet.notifications.send(accountId, kind, payload)
fignet.analytics.track(accountId, eventName, payload)
fignet.kv.get(scriptKey, k) / fignet.kv.set(scriptKey, k, v)
fignet.log.info(msg) / .warn / .error

Sandboxing: no eval, no arbitrary require, no network outside fignet.*, no FS, memory cap, per-script CPU budget. Scripts that exceed the budget consecutive times get auto-disabled (ScriptTelemetry) - there's a wired threshold callback in Program.cs:583-592.

There's also an RPC surface, /v1/rpc/{name} (player JWT) and /v1/internal/rpc/{name} (S2S). Each registered RPC declares a permission (public / authenticated / admin) and an optional rate-limit policy.

9. Background work

9a. Worker process

FigNet.Persistence.Worker is a Host.CreateApplicationBuilder process that today hosts two services:

  • HeartbeatService - a placeholder. Future stream consumers (analytics shipping, durable outbox) will live here.
  • WorkerRestartListener - subscribes to Redis worker:control:restart. On signal, calls IHostApplicationLifetime.StopApplication() then Environment.Exit(0) after a 2s grace; Docker's unless-stopped policy resurrects.

9b. JobScheduler (in the API process)

Despite the name, the cron tick runs in the API, not the worker - see Program.cs:357. 1-minute Cronos tick + Redis lock per (app_id, name) for distributed at-most-once. Each tick consults scheduled_jobs, picks rows whose cron is due, and dispatches via JobRunner.

Built-in job handlers (each implements IBuiltInJobHandler):

  • refresh-token-gc - purges expired sessions rows.
  • archive-cleanup - purges old *_archived mirror rows.
  • notification-broadcast - schedules a broadcast notification.
  • daily-reward-grant - applies a profile template to a cohort.
  • apply-template - generic scheduled template apply with audience filter.
  • backup-pg - pg_dump | gzip -> S3.

A kind=script job invokes a registered TS RPC instead.

Operators see this in the dashboard's /jobs page; CRUD via /v1/admin/jobs/*.

9c. Analytics rollup

AnalyticsRollupScheduler is its own hosted service - runs hourly + daily rollups from the partitioned raw event tables into the smaller summary tables that the dashboard reads.

10. Observability

Three layers.

10a. Logs - ZLogger

Same choice as EntangleServer. Two sinks:

  • Console - JSON-per-line. This is what docker logs shows and what log shippers consume.
  • Rolling file - plain text, day-rolled, 50MB/file, under PERSIST_LOG_DIR (default /app/logs). The dashboard's /logs page reads these via /v1/admin/ops/logs/*.

Minimum log level is mutable at runtime - RuntimeLogLevel.cs. The dashboard's /ops/log-level page flips it; effect is immediate, no restart needed. Resets to Information on next process boot.

10b. Metrics - OpenTelemetry -> Prometheus

ASP.NET Core + HttpClient + cache meters are exported on :8091/metrics (Prometheus scrape format). The persistence-specific meter is FigNet.Persistence.Cache (CacheMetrics.cs) - fignet_cache_hit_total{key_prefix=...} etc.

OTLP export is optional - set OTEL_EXPORTER_OTLP_ENDPOINT to ship traces + metrics to an external collector (Tempo, Honeycomb, etc).

The dashboard's /ops/cache page reads the in-process snapshot (CacheMetrics.Snapshot) rather than scraping Prometheus, so the in-product view works even without a Prometheus deployment.

10c. Audit log

AdminAuditService writes a row to admin_audit for every admin write. Schema: (admin_id, action, target_kind, target_id, payload_hash, result, ts). The dashboard's /audit page reads this through a paginated endpoint.

The non-negotiable rule: every admin write emits an audit row, including the failure path. If you add an admin endpoint, you add an audit emission.

11. Hot-path conventions

These are conventions baked into the codebase for the hot path. Follow them or the bench regresses.

  • Source-gen JSON only. Every DTO + every collection of DTOs has an entry in PersistenceJsonContext.cs. Reflection-based JSON throws at runtime.
  • linq2db nested-tx detection. If your service might be called inside someone else's transaction, check db.Transaction != null and join. Don't start a fresh one.
  • ValueTask only where it pays. Specifically, cache-aside hot paths - AccountService.GetByIdAsync, ProfileService.GetAsync, ShopService.GetCatalogAsync, RemoteConfigService.ResolveAsync. Not blanket - the rest stay Task because ValueTask doesn't help on the cold path and complicates await patterns.
  • ArrayPool<byte>.Shared for hot byte-buffer allocations - HMAC composition in S2sHmac.cs and Redis Streams payload composition in EventPublisher.cs. Always try { ... } finally { ArrayPool.Return(buf); }.
  • Cache-aside with explicit invalidation. Every mutating service post-commits an _invalidator.InvalidateAsync(keys) call. Keys are centralised in CacheKeys.cs - never hand-format a cache key inline.
  • Cross-replica cache fanout. RedisCacheInvalidator publishes to cache:invalidate so other API replicas drop the entry too.
  • Streaming HMAC. S2S body validation is chunked through IncrementalHash, not buffered. The request body is not consumed by the guard for downstream handlers - handlers that re-read the body (e.g. multipart upload) must re-wrap it from the buffer they got back, see BackupsEndpoints.cs.
  • Idempotency keys. Payment receipt validation + shop purchase + wallet ledger all key by an opaque idempotency_key so a retried client request can't double-charge. Stored in PG with a unique constraint.
  • [SkipLocalsInit] on tight inner loops (e.g. parsers, codec primitives). Documented per file.

12. The middleware-free admin auth path

One quirk worth flagging. The admin pipeline does not use ASP.NET's AddAuthentication for the admin role. The admin JWT is on the EntangleServer side; what arrives at Persistence :8091 is only S2S HMAC + the X-Admin-* headers. Each admin endpoint manually calls S2sGuard.CheckAdminScopeAsync(http, requiredScope) at the top of the handler and the handler bails out with the returned reject result if it fails. This is intentional - it keeps the admin-scope check next to the endpoint code that reads the X-Admin-Sub and writes the audit row.

13. Multi-tenancy

Every player-facing request carries an X-App-Id header (canonical) or the legacy X-FigNet-App header (still accepted for older SDK builds). The Unity SDK is constructed with an app id and adds it automatically; admin calls pass it explicitly via the EntangleServer proxy. Once a player has a JWT, the app claim baked into the token is the canonical source - the SDK doesn't need to keep sending the header on authed calls.

Without an app id, requests 400 with app_id_required. There is no silent "default" bucket - that fallback was removed because it let misconfigured clients accidentally blend into a fake shared tenant. The single resolver lives at FigNet.Persistence.Api/Internal/AppIdResolver.cs.

Segregation guarantees:

  • Every player-facing row carries app_id. Indexes lead with app_id so the query planner prunes per tenant.
  • Friendships are app-scoped (friendships PK is (app_id, account_id, other_id)).
  • Bans are per-app (account_bans.app_id). Banning a cheater in App A doesn't kick them from App B. Redis blocklist SETs are per-app (bans:active:<appId>).
  • Sessions are per-app - revoke on ban scopes to one app.
  • Payment dedupe is app-scoped - same Apple tx_id can legitimately exist in two apps (rare but architecturally clean).
  • Realtime topic format for shared-name resources is leaderboard:<appId>:<slug>. Account-keyed topics (wallet:<accountId>, profile:<accountId>, metadata:<accountId>:<ns>) use the globally-unique UUID directly, so no app segment is needed.
  • Cross-app target = 404, not 403. No existence-side-channel across tenants.

App ids are still arbitrary strings - there's no apps table today. A future phase is expected to introduce one as part of a customer-portal capability; existing rows with app_id='default' would be associated with a sentinel default app owned by the platform operator.

14. Configuration

There are three layers, in order of precedence (later overrides earlier):

  1. Persistence.yaml - defaults baked into the image. Mounted to /app/api/Persistence.yaml. Override the whole file by mounting a different one.
  2. Environment variables - every secret-bearing field has a *_Env counterpart that names the env var to read. Set in compose / k8s secrets / .env. The canonical list:
Env varPurpose
PG_PASSWORD / PG_CONNECTION_STRINGPostgres credentials
REDIS_CONNECTION_STRINGRedis (e.g. redis:6379)
PERSIST_JWT_SECRETSigns player + admin JWTs
PERSIST_S2S_SECRETEntangleServer -> Persistence HMAC
PERSIST_S3_BUCKET / PERSIST_S3_ACCESS_KEY / PERSIST_S3_SECRET_KEYObject storage
PERSIST_ADMIN_BOOTSTRAP_USERNAME / _PASSWORDSeed first admin on empty admin_users (no-op after)
PERSIST_CONFIG_PATHOverride the YAML path
PERSIST_LOG_DIRWhere the rolling files write (default /app/logs)
PERSIST_MODEapi or worker (Dockerfile dispatch)
OTEL_EXPORTER_OTLP_ENDPOINTOptional OTLP target
SMTP_DSNEmail-verify provider (if Email auth enabled)
GOOGLE_CLIENT_ID / APPLE_TEAM_ID / etc.External auth providers
  1. system_flags table - runtime-mutable settings that the dashboard edits. maintenance_mode, ban_blocklist_enabled, rate_limit_overrides. Read at request time (with in-process caching + Redis fanout for cross-replica freshness).

The dashboard also has a direct YAML editor at /persistence-config - it edits the file on disk and triggers a restart. Use it sparingly; treat the file as the source of truth.

15. Development workflow

One command:

docker compose -f docker-compose.dev.yml -f docker-compose.persistence.yml --profile embedded-db up --build

This brings up: EntangleServer + dashboard + Persistence.Api + Persistence.Worker + Postgres + Redis + MinIO. The dashboard is at http://localhost:8080. The bootstrap admin is whatever is set in the dev environment file (which is never committed to source control).

Running a single test project:

dotnet test FigNet.Persistence.Tests/FigNet.Persistence.Tests.csproj

There is no aggregating solution that runs every test - each project stands alone. CI does not currently run persistence tests.

Running the bench:

FigNet.Persistence.Bench is a BenchmarkDotNet project separate from xUnit. It is not run in CI. Use it locally before / after a perf-sensitive change.

Adding a new endpoint:

  1. DTO records in the matching Api/{Feature}/{Feature}Dtos.cs. Register every record in PersistenceJsonContext.cs - both the record itself and any List<> / array shapes you return.
  2. Endpoint method in Api/{Feature}/{Feature}Endpoints.cs with [Map{Feature}Endpoints].
  3. Service method in Core/{Feature}/{Feature}Service.cs. If you cache, register a CacheKeys.* entry and invalidate on writes.
  4. Migration .sql file under Core/Migrations/ (next number, embedded in csproj).
  5. If admin-facing: declare scope, write audit. If player-facing: declare rate-limit policy.
  6. Add an SDK module + a demo controller.

Adding a new migration:

FigNet.Persistence.Core/Migrations/034_my_change.sql

DbUp picks it up automatically on next startup. Never edit a shipped migration. If you need to fix a mistake, write a new migration.