tcg-platform¶
Current role¶
tcg-platform is the staging replacement platform. It is not yet production authority.
The platform should replace Google Sheets/Apps Script, pieces of exzen-core, and parts of the operational finance/inventory stack with a PostgreSQL-backed Medusa system.
Architecture¶
Monorepo components:
apps/server: Medusa 2 backend (Yarn 4 workspace).apps/dashboard: Next.js operator dashboard (OpenNext build, deployed to Cloudflare Workers).apps/storefront: Next.js storefront — a standalone npm app, not a root Yarn workspace member (it installs from its ownpackage-lock.json).- Root Yarn workspaces are
apps/server,apps/dashboard, andpackages/*. The only package underpackages/today ispackages/shared-types(shared TypeScript types); there are no separate API-client, connector-SDK, UI, lint, or TS-config packages. - PostgreSQL (
postgres:16-alpine) for the intended commerce/finance source of truth. - Redis (
redis:7-alpine) with AOF persistence and a namedredis-datavolume; it backs the Medusa event bus, workflow engine, cache, and locking. - Custom modules for inventory/imports, DCA, connectors, finance, profitability, RBAC, audit, durable operations, and storefront projection.
- Compose project is pinned to
name: tcg-platformso volumes/networks are stable regardless of which directory runsdocker compose(prevents drift toserver_*-prefixed volumes on pre-monorepo hosts).
Durable operations¶
Runs on the durable outbox spine (Lane C) inside the Medusa process — not a separate worker service. MEDUSA_WORKER_MODE: shared runs the HTTP server and background worker/subscribers in one container (docker-compose.yml).
- Model: append-only outbox (
ops_outbox_event), operation runs (ops_operation_run), and attempts (ops_operation_attempt) with DB-clock lease fencing, idempotency keys, and causal attempt outcomes. Failed events land in the exception queue for operator review; replay tooling can re-run a stored raw event without duplicating side effects. - Execution: the poll-driven drain (
src/jobs/ops-worker-drain.ts) runs every 10s: it reclaims stale leases, leases ready outbox events, and drives each throughappend STARTED attempt → executor.execute → finalize(run-attached) orexecute → complete/requeue(standalone). An immediateops.enqueue.kicksubscriber fires right after enqueue, so events are picked up without waiting for the next 10s tick. - Executors:
internal— the default tracer executor (no provider I/O).OPS_EXECUTORonly ever acceptsinternal, so the default can never silently become a provider write.inventory-sync— the deliberate provider-writing executor forinventory.sync.requestedevents: it pushes a variant's CURRENT stock to its Shopee/Lazada listings with desired-state semantics (quantity re-read at execution time). Routing is per event type viaresolveEventExecutor(in both the drain job and the kick subscriber), not env-selectable.- Inventory changes are enqueued on the outbox per (variant, channel) from the
inventory.inventory-level.updatedevent; a 1-second bucket (bucket:<floor(ms/1000)>) coalesces bursts so one run/event carries the batch, andavailableAt = now + 1sguarantees every absorbed change is committed before the executor's desired-state read. - Operator API:
GET /admin/ops/{status,outbox,runs,attempts}(view_exceptions) andPOST /admin/ops/probe(manage_connectors), which enqueues an internal tracer operation to prove the loop live. - Tuning env (all optional):
| Var | Default | Meaning |
|---|---|---|
OPS_WORKER_ENABLED |
on |
false/0 disables every drain path (scheduled job AND immediate kick) |
OPS_WORKER_ID |
ops-worker |
lease-owner label |
OPS_WORKER_LEASE_MS |
30000 |
lease duration |
OPS_WORKER_BATCH |
10 |
max events per tick |
OPS_WORKER_MAX_ATTEMPTS |
8 |
auto-retry cap (beyond → permanent/dead) |
OPS_WORKER_REQUEUE_DELAY_MS |
5000 |
standalone requeue backoff |
OPS_EXECUTOR |
internal |
default executor name (only internal allowed) |
Authentication (as of 2026-09-18)¶
BetterAuth is the credential + sign-in engine; Medusa remains the token and authorization source (connect.sid sessions, authenticate(...) middleware, staff.user_role). BetterAuth is mounted at /better-auth/* with its own ba_* tables (same Postgres).
- Staff: BetterAuth-first dashboard login (
loginWithBetterAuth: enrol → link → exchange → session); the legacy emailpass flow is the forced-fallback recovery path. - Customers: email+password sign-in on the storefront, BetterAuth-first for linked identities with the legacy bridge as fallback.
- Telegram: a first-class BetterAuth provider (mini-app
initData+ Login-widget OIDC), flipped on staging behindTELEGRAM_BA_LOGINwith silent fallback. Second door: Telegram-only customers can add an email+password (verification email via Resend) and sign in on the web with the same account and order history. - Email: transactional email (verification, resets) via Resend hooks; suppressed and logged when
RESEND_API_KEYis unset. - Gotchas encoded in code: BetterAuth's CSRF guard rejects origin-less sign-in POSTs (it is NOT browser-only), and session-gated calls must present the resolved cookie name + SIGNED value. Read
apps/server/src/lib/better-auth/before touching auth.
Staging runtime snapshot¶
As of 2026-08-13 (post Ampere migration):
- Host: OCI
ampereVM (VM.Standard.A1.Flex, 4× ARM64 Neoverse-N1, 24 GiB RAM, 100 GB disk, Ubuntu 24.04, tailnet100.85.99.41). Staging previously ran on Proxmox CT105tcg-staging; that container was destroyed after migration. - Stack: Docker Compose project
tcg-platformat/srv/tcg-platform/apps/server— Medusa, storefront, PostgreSQL, Redis. Images are native arm64 builds. - Data: PostgreSQL restored from the pre-migration dump; products 186, variants 385. Redis was recreated empty during the one-time migration, but it now runs with AOF persistence (
--appendonly yes) and a namedredis-datavolume, so routine restarts no longer wipe in-flight state; transient queues/cache regenerate only after a fresh volume. - Ingress: dedicated Cloudflare tunnel
exzentcg-ampere(systemdcloudflared-ampereon Ampere) routestcg-staging.exzentcg.com → 127.0.0.1:9000andshop-staging.exzentcg.com → 127.0.0.1:8000.tcg-stagingsits behind Cloudflare Access;shop-stagingis public. The oldexzentcg-homelabtunnel and the NPM proxy hosts that used to forward to CT105 no longer carry these hostnames. The trackedapps/server/docker-compose.ymldeclares9000:9000and8000:8000(all host interfaces); Ampere's local checkout carries a host-only edit to127.0.0.1:9000:9000/127.0.0.1:8000:8000and marks that fileskip-worktree. This is a host-local security prerequisite, not a tracked Compose invariant: after any fresh checkout or deploy, verify the rendered bindings (for example, withdocker compose configordocker ps) before exposing the tunnel. - Shopee and Lazada connector profiles exist; marketplace inventory sync toggles are off. The provider-writing durable inventory-sync executor exists, but the per-connector
sync_enabledgate remains the safety boundary.
Runtime configuration¶
- Required secrets: compose requires
JWT_SECRETandCOOKIE_SECRETvia${VAR:?}(inapps/server/.env) — a fresh/default deployment cannot boot with a known static secret. Production boot (NODE_ENV=production) additionally refuses to start withoutREDIS_URL; the event-bus/workflow/cache/locking modules must use real Redis. - Cookie scope: set
COOKIE_DOMAIN=.exzentcg.comon the server side (medusa-config.tscookieOptions); the dashboard re-emits the same cookie from its login action and restates the scope with its ownSESSION_COOKIE_DOMAIN(apps/dashboard/wrangler.jsoncvars, resolved inlib/session-cookie.ts). Two processes, two env vars, one value — change one, change the other. Unset in dev; browsers reject an explicitDomain=forlocalhost. - Credential boundary: Shopee/Lazada credentials and OAuth/return URLs come from the host env (
.env/systemdEnvironmentFile), never committed; postgres creds come from the compose service env sodeploy-server.shneeds no stored passwords.
Staging is active development, not a stable production mirror.
CI/CD¶
GitHub Actions, defined in .github/workflows/. The required status check aggregate accepts only success or path-filter skipped — job cancelled (manual cancel, timeout) is treated as a failure.
CI (.github/workflows/ci.yml)¶
- Triggers: pull requests to
mainand stacked worktree brancheswt/**, plus pushes tomain. Superseded runs on the same ref are cancelled; a cancelled run is never validation on its own. - Path classification: plain
git diff(no third-party action) computesserver,dashboard,storefront,docs, andcioutputs; heavy lanes only run when their path set changed, but theaggregatecheck always runs so every PR keeps one stable required check. - Runner trust boundary: same-repository HUMAN pull requests and push-to-main run on the persistent self-hosted
dev-cirunners (persistent Yarn + local BuildKit caches); fork and bot (e.g. Dependabot) PRs stay on GitHub-hostedubuntu-latest, because a self-hosted runner executes PR-controlled code on a persistent machine. - Server lane (
build): server unit tests (always-on gate), the Medusa build,tsc --noEmit(after the build generates.medusa/types), and atcg-platform:ciDocker image build (BuildKit cache split by runner type). - Self-hosted Docker cleanup: after the smoke build's
load: truestep, the server lane best-effort runsdocker image prune -afanddocker builder prune -af --filter "until=24h". This removes per-run unused image copies and stale internal BuildKit state; both commands are self-hosted-only and ignored on failure because the fourdev-cirunners share a rootless daemon and cleanup can race another build. The persistent/opt/gha-cache/buildkitlayer cache is deliberately untouched; hosted runners self-clean and skip this step. - Dashboard lane (
dashboard): dashboard unit tests, then exactly one Next + OpenNext build (opennextjs-cloudflare build), packaged asdashboard-opennext.tar.gzand — onmainpushes only — published to the runner-host hand-off for that run:/opt/tcg-ci-artifacts/<run-id>/holding the tarball, asha256sumfile written inside that directory, and amanifest.txtwithsha=<commit>/run_id=/ref=. No GitHub artifact storage is involved — it blocked every dashboard deploy while its org quota figure was stale, and it is now off the deploy path entirely. This remains the sole build. - E2E: the per-PR CI lane has no e2e job.
dashboard-e2elives in the opt-in/nightly Full regression workflow, builds its own dashboard (yarn workspace @tcg/dashboard build, thentest:e2e), and uploads only its Playwright report — it does not consume the deploy bundle. - Redis/BullMQ integration (
integration-redis): runs the real ops-outbox integration suite against Postgres + Redis service containers (event-bus-redis enabled, durable-ops delivery/retry/fencing) whenever the server lane is touched. - The classifier's
storefrontoutput gates astorefrontjob that installs, lints, type-checks, and runs the storefront unit suites (test:stock,test:freshness,test:auth,test:unit). It does not compile/build the storefront — do not rely on storefront-compile CI coverage today.
Weekly container smoke (.github/workflows/container-smoke.yml)¶
Monday 03:15 UTC, or manual workflow_dispatch, 60-minute budget, hosted runners. Builds the Medusa image, boots fresh Postgres + Redis, runs the full migration graph against the vendored Yarn release, then boots Medusa and requires /health = 200. Deliberately not a per-PR gate (fresh-DB migrations are minutes-long); per-PR migration safety comes from the migration unit suites and the deploy script's preflight/migration/rollback.
Staging access & deploy¶
- Reach the host:
tailscale ssh ubuntu@100.85.99.41(repo at/srv/tcg-platform). - Runtime path note: Ampere's checkout is
/srv/tcg-platform, and the script's defaultREPO_ROOT/COMPOSE_DIRfollow that checkout. The script still defaultsBACKUP_DIRto/opt/backups/tcg-platformandDEPLOYED_SHA_FILEto/opt/tcg-platform/SERVER_DEPLOYED_SHA; set those environment variables explicitly if the host's/srvlayout is canonical for backups and deployment records.
Server deploy (manual)¶
scripts/deploy-server.sh, run ON Ampere. It is a manual, pinned-commit procedure — there is no auto-deploy on push for the server:
- Lock (single deploy at a time) + fetch, then pin an exact SHA (
[SHA]arg ororigin/main). - Candidate-compose preflight: validates the compose file of the tree being switched TO (read from git, never the current tree) before anything changes.
- Full PostgreSQL backup (custom-format
pg_dump→ gzip, integrity + sha256 checked) with retention, default to/opt/backups/tcg-platform/. - Switch to the pinned SHA; optional migration preflight (
--check-migrations) runs post-switch/pre-up and rolls back on failure. docker compose up -d --build medusa— rebuilds only themedusaservice; Redis/Postgres/storefront service-spec changes need a separate explicit Compose apply.- Real
/healthwait; on any post-switch failure (build, health) it rolls back to the previous SHA and prints the DB-restore instruction. Records the deployed SHA.
Options: [SHA], --check-migrations, --dry-run, --help.
- Build order trap: start Medusa first, then build the storefront — the storefront
next buildcollects page data from Medusa onlocalhost:9000and fails withECONNREFUSEDif Medusa is not up. - Verification: on-host
curl 127.0.0.1:9000/health→ 200; publichttps://shop-staging.exzentcg.com/sg→ 200;https://tcg-staging.exzentcg.com/connectors/shopee→ 404/410 means Medusa is reachable through the Access-exempt path.
Dashboard deploy (automatic after green CI)¶
Staging dashboard deploys to Cloudflare Workers via the deploy-dashboard-staging workflow — not a direct push trigger:
- Fires on successful push-to-main CI completion (
workflow_run), checks out the exacthead_shaCI validated, and promotes the bundle CI published to/opt/tcg-ci-artifacts/<the triggering run id>/— verifying directory + files exist, thatmanifest.txtcarries exactly thathead_sha(grep -qx "sha=<sha>"), and that the checksum passes, before anything is extracted. No rebuild fallback: every miss refuses loudly. No second build in deploy. - A path filter (dashboard, shared types, the workflow,
yarn.lock,package.json) can skip deployment;workflow_dispatchis the manual fallback, which builds once because there is no CI artifact to promote. - Runs on the self-hosted
dev-releaserunner with thestagingenvironment andCLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_IDsecrets. -
Hand-off precondition: it is host-local by construction — every
dev-cirunner and thedev-releaserunner live on the same machine, so the deploy lane reads what CI wrote. If the deploy runner ever moves to another host, the hand-off must move with it (shared mount) or the promotion refuses. Retention is 2 days, pruned by the publishing step; a re-run publishes a fresh directory for the same run id, and the manifest'ssha=line is what proves which commit a directory holds. -
Storefront deploy: auto on main pushes (
deploy-storefront-stagingworkflow, trusts successful main CI); manual pinned pathscripts/deploy-storefront.sh <SHA>on Ampere. The server remains manual (deploy-server.sh). - CI: lean merge lane per push + opt-in/nightly Full regression; mandatory
ci/fullsurfaces and the landing process live in the repo'sdocs/CI-REVIEW-GATE.md. Merges run throughscripts/land-pr.mjs(exact-head pinned).
The staging server/dashboard deploys ship staging only; production is the co-hosted tcg-platform-prod stack described above (api-tcg.exzentcg.com + shop.exzentcg.com + dashboard.exzentcg.com). It still must not become the marketplace stock writer until exzen-core is retired — see the boundary below.
What exists now¶
tcg-platform already has strong foundations for:
- Product/variant/inventory models.
- Purchase orders, imports, fees, allocations, receipts, and batch lifecycle.
- DCA/consolidation with provenance and cost conservation.
- Shopee inbound event/idempotency architecture.
- Durable operations spine: outbox/run/attempt on DB-clock leases, retry/fencing, exception queue with operator review UI, raw event store, and replay/reconciliation tooling.
- Connector exception surface and alerting.
- Order COGS allocation and profitability.
- Finance statement/CSV/P&L logic.
- RBAC/audit framework.
- Dashboard operator workflows.
- Dashboard navigation and pages: a single Products surface with Catalog + Stock tabs (stock console with per-channel drift lives under the Stock tab; the old
/inventorypage redirects there). - Opening-stock migration tooling and reconciliation.
Production (co-hosted on Ampere, live 2026-09-23)¶
Production is a second Compose stack on the same host as staging, not a separate machine. The stack identity, ports and every derived path come from one knob per stack (see apps/server/docker-compose.yml + scripts/deploy-server.sh).
| staging | production | |
|---|---|---|
| checkout | /srv/tcg-platform |
/srv/tcg-platform-prod |
| Compose project / container prefix | tcg-platform |
tcg-platform-prod |
| server | https://tcg-staging.exzentcg.com (Access-gated) |
https://api-tcg.exzentcg.com — owner-only except three explicit bypasses: /store/* (added 2026-09-28 for the storefront's browser-side flows: customer auth under /store/auth/better-auth/*, carts, the Telegram Mini App), /better-auth/verify-email* + /reset-password* (email links), /connectors/* (marketplace webhooks) |
| storefront | https://shop-staging.exzentcg.com (public) |
https://shop.exzentcg.com (public) |
| dashboard worker | tcg-dashboard-staging → dashboard-staging.exzentcg.com |
tcg-dashboard → dashboard.exzentcg.com |
| loopback ports | 9000 / 8000 | 9001 / 8001 |
| server deploy | scripts/deploy-server.sh on the host |
/srv/tcg-platform-prod/deploy-prod.sh server <sha> |
| storefront deploy | scripts/deploy-storefront.sh (auto lane on main) |
/srv/tcg-platform-prod/deploy-prod.sh storefront <sha> |
| dashboard deploy | deploy-dashboard-staging (auto on green main CI) |
deploy-dashboard-production (manual dispatch, exact SHA) |
| provenance markers | /opt/tcg-platform/SERVER_DEPLOYED_SHA, <clone>/STOREFRONT_DEPLOYED_SHA |
/opt/tcg-platform-prod/SERVER_DEPLOYED_SHA, /srv/tcg-platform-prod/STOREFRONT_DEPLOYED_SHA |
- Config is per-stack
.env.TCG_STACK_NAME,MEDUSA_HOST_PORT,STOREFRONT_HOST_PORT,STOREFRONT_BUILD_MEDUSA_URL,STOREFRONT_IMAGEand the CORS/trusted-origin vars (STORE_CORS,ADMIN_CORS,AUTH_CORS) are read from that stack'sapps/server/.env. The deploy scripts refuse a shell-only or disagreeing value for the identity knobs, and derive the health probe, provenance marker, backup directory and rollback file from the resolved stack — so a co-hosted deploy cannot borrow another stack's provenance or backups.LOCK_FILEis deliberately shared (one deploy at a time per host). - First boot of a stack is manual (
/srv/tcg-platform-prod/bootstrap-prod.sh server|storefront <sha>): the deploy scripts require a provenance marker plus a running container to prove the rollback target, so a fresh stack is booted once by hand and its marker is then written from the running image's own revision label. - Production started empty (no data copied from staging): region Singapore/SGD, the Default Sales Channel, one stock location, a publishable key carrying
STOREFRONT_PUBLISHABLE_KEYfrom the prod.env, and one admin user (admin@exzentcg.com; credential held by the owner). Catalog import is a separate go-live step. - Cloudflare: dedicated Access apps
exzen-tcg-prod(+/connectors/*and/better-auth/verify-email|reset-passwordbypass apps) andexzen-tcg-dashboard-prod(+-staticbypass), aexzen-tcg-dashboard-worker-prodservice token, and two ingress rules added to the existingexzentcg-amperetunnel.shop.exzentcg.comis public, like staging's storefront. - Deliberately dark in production v1: marketplace connectors (no live credentials placed;
exzen-coreremains the active stock writer) and Telegram (needs its own bot + OIDC client). Transactional email (Resend) is wired. - BetterAuth on a fresh DB: after the first boot, run
medusa exec ./src/scripts/better-auth-migrate.tsinside the container and restart medusa — otherwise every/better-auth/*request 500s with "Database schema mismatch". - Production dashboard deploys deliberately:
deploy-dashboard-production(dispatch-only —sha+ a typedconfirm) promotes the bundle CI published to the runner-host hand-off for that exact SHA's CI run; it never builds on the release runner, deploys the production worker config only, probes the Access-gated custom domain with a Cloudflare Access service token (never following redirects) and requires the worker'sworkers.devroute to answer non-200, and posts arelease-lifecycle-productionrecord. It refuses a SHA that is not onmain, that has no successful push-to-main CI run, or whose hand-off directory is missing or already pruned (2-day retention). First real run 2026-09-25: it promoted46fc7aa8(CI run36011004861, the same run the staging lane had consumed) to worker versionfbe1ae4d, smoke200, and posted the record. - Bring-up gaps, updated 2026-09-28: both dashboards track
mainthrough their lanes — staging auto-promoted236c56ec(workerb4cc8fdb, 2026-09-25); production promoted236c56ec(worker00b95cd9) and then1c0e05b8(PR #671, workerf739bdfa, 2026-09-28), each posting arelease-lifecycle-productionrecord. The prod server stack was caught up to24bc45a3on 2026-09-28 (deploy-prod.sh server <sha>): the script took its own pre-deploy dump (/opt/backups/tcg-platform-prod/medusa-20260928T155048Z-pre-24bc45a3….sql.gz), ran the pending migration scripts, and passed its health probe; the provenance marker reads24bc45a3. The state-model slice that came with it (S4 backfill / S5 cleanup) needed no data work on prod — both scripts' dry runs reportorders scanned=0/overrides total=0, so nothing was backfilled or deleted. Prod also now ships orders state-model S4+S5 and the purchasing PO-list bulk actions (#663/#664/#668). Production's dashboard is now Access-only — verified live:dashboard.exzentcg.com/login302s unauthenticated andtcg-dashboard.exzensg.workers.dev/loginanswers 404 (it answered 200 unauthenticated before this deploy). Note for the lane: theworkers.devcheck has no propagation grace window, so the first post-#671 run failed on Cloudflare's lag seconds after the deploy while the route was already gone a minute later; its record was superseded by the re-dispatch's success. A retry window on that check is an open follow-up. Production's earlier manual build (d209cf41) is long superseded. Still open: the storefront is browse-only until a public/store/*path exists (same shape as staging). Product images are all hot-linked from marketplace CDNs — measured 2026-09-28: staging holds 1267 image rows, none on the local file provider (829cf.shopee.sg, 242 on Lazada's test CDNsg-test-11.slatic.net, 185sg-live.slatic.net, 10filebroker-cdn.lazada.sg, 1 legacyr2.devURL), and production holds none yet. Nothing is therefore lost when the local store is wiped on rebuild, but the hot-links are outside our control — the Lazada test CDN especially. The durable path is live in staging as of 2026-09-28: buckettcg-product-imagesbehindimages.exzentcg.com(S3 write and public read verified), the compose passes all sixS3_*vars through, and staging's.envnow sets them (pre-change backup kept, mode 600 preserved; medusa recreated with all six keys visible inside the container and healthy on :9000). Production is deliberately left unwired until a real upload through staging has survived a rebuild — a proof that still needs an admin session, and no admin credentials are stored in BWS. Also open: the optional mirror of the 1267 hot-linked image URLs into the bucket, and the same.envwiring on production.
Public store API (added 2026-09-28)¶
api-tcg.exzentcg.com/store/* is public via the Access app exzen-tcg-prod-store
(53f11a69-f77c-491a-bf8c-f5522518b7c3, policy Store API Bypass, decision=bypass).
The storefront's SSR never needed it — it reads Medusa over the internal docker
network — but the browser-side flows do: customer auth (/store/auth/better-auth/*),
carts, and the Telegram Mini App. Everything else on the API stays owner-only
(verified: /admin/products, /health, /storeX decoy and / all still 302).
One Free-plan rate-limit rule guards the anonymous write paths: POST /store/carts
and POST /store/customers → block, 20 req / 10s per IP, 10s mitigation
(ruleset 5aedbafb85b74975acbfaebd5e14e09a). Verified by load test: 28–37 × 429
per 60 parallel writes, reads unaffected.
Rollback records live in ~/workspace/tcg-prod/access-changes/.
Cloudflare Free-plan rate-limit traps¶
Found by making each mistake against the live API: the entrypoint PUT rejects
kind; characteristics must include cf.colo.id; mitigation_timeout must be
exactly 10; managed_challenge is not entitled; matches (regex) needs a paid
plan; and starts_with is accepted at validation but silently never matches —
a rule that looks correct and enforces nothing. Always prove a rule with a real
load test rather than reading its expression.
Historical incident notes¶
2026 Shopee connector audit and staging incidents records lessons from the former CT 105 staging environment; it is not a current deploy guide.
15 June 2026 — Orders spreadsheet view lessons (from wiki PR #57, not a current phase status):
- Medusa generated listers and
query.graphdefaulted to 15 rows. Batched DCA allocations/adjustments/batch-items, Shopee escrows, and variant lookups could silently truncate, understating COGS or dropping fees/metadata. Page to exhaustion or set an explicittakesized for the requested keys; small fixtures do not exercise this boundary. The DCA economics implementation pages its listers, while order enrichment sets explicit limits. query.graphreads sorting frompagination.order, not a top-levelorderBy(silently ignored when cast toany). Use a multi-key order with a uniqueidtiebreaker for stable offset pagination; see the Orders route and its graph pagination.
Current production boundary¶
Do not treat tcg-platform as the source of truth yet.
In particular, do not enable marketplace stock sync while exzen-core remains the active stock writer — now doubly important, because tcg-platform has a live provider-writing inventory-sync executor (durable ops, above) that would contend with exzen-core for the same marketplace listings.