Skip to content

Architecture

cheyras edited this page Oct 12, 2026 · 33 revisions

DeckPal — Architecture

Status: Target architecture for the cloud pivot, drafted 2026-08-09. This document supersedes the prior self-hosted architecture. Historical design decisions are preserved in decisions/; DECISIONS.md explains the archive.

This document is the synthesis. It states what we are building and why, and points at the research documents that justify each choice. It deliberately does not repeat their evidence -- where a number appears here, the citation tells you where it was measured.

Document What it settles
Data Layer (wiki) Catalog ingest, prices, images, storage engine, sync jobs
research/SCHEMA.md Tables, DDL, indexes
research/DECK-FORMATS.md Legality rules, PTCGL grammar, format data sources
Dex Data (wiki) Species mapping, sprites, capture semantics
research/REMOVED-RESEARCH.md Tombstone for the retired reference-capture specs (product behavior, URL structure) -- removed 2026-08-31
UI Spec (wiki) Design tokens, components, layout
Frontend Research (wiki) Frontend stack + performance plan
Prior Art (wiki) What to borrow, what to avoid, license posture
decisions/ Locked decisions + corrections to the original brief; DECISIONS.md is the guide

1. The one-paragraph version

DeckPal is a multi-user Pokemon TCG collection platform deployed on Vercel (serverless API + SPA) and Supabase (Postgres with RLS, Auth, Storage CDN). It holds its own copy of the card catalog, cached card art, and accumulating price history -- so it keeps working if every upstream vanishes. Card data is imported from TCGdex's open database, prices come from TCGCSV and Cardmarket bulk dumps, and both are pulled by scheduled GitHub Actions jobs. The repo is open-core (AGPL-3.0) and can also be self-hosted with plain Postgres.

2. Guiding constraints

  1. Cloud-first, self-host-compatible. The primary deployment is Vercel + Supabase. The repo must also work with plain Postgres and no Supabase (see section 9).
  2. Offline resilience is structural, not a fallback. The read path is Postgres + Supabase Storage CDN (or local image cache for self-host). There is no proxy-on-demand, so there is no network failure mode to handle in the UI.
  3. Own the data. Every sync is additive and writes to the database. Price history accrues to us and is never re-fetched from a provider's retention.
  4. Multi-user with row-level security. Every user sees only their own collection, decks, and lists. Catalog and pricing data is shared.

3. Target topology

Clients (browser, PWA, MCP agents)
       |                     |                      |
       | Auth (JWT)          | Data (JWT Bearer)    | Images (public CDN)
       v                     v                      v
Supabase Auth        Vercel Serverless       Supabase Storage
(email + OAuth)      Functions               card-art bucket (~1.9 GB)
                     Express catch-all       CDN + image transforms
                     (api/index.ts)
                            |
                            | service-role or user JWT
                            v
                     Supabase Postgres (RLS)
                     Supavisor connection pool
                            ^
                            | service-role (direct)
                     GitHub Actions
                     Scheduled sync (catalog, prices)

Only the sync jobs cross the network boundary to upstream data sources. A user request never leaves the Vercel + Supabase perimeter.

4. Repo and deployment shape

Decision: Keep the Vite SPA + mount the Express app as a single catch-all Vercel serverless function.

The Vite SPA (React 19 + TanStack Router + TanStack Query + Tailwind 4) is mature and explicitly out of scope for a framework migration. The ~55 Express API endpoints are served by a single catch-all function (api/index.ts) that imports the Express app and exports it as a Vercel serverless handler.

api/
  index.ts              <- Vercel catch-all: imports Express app
apps/
  api/src/              <- existing Express app (unchanged)
  web/                  <- Vite SPA (Vercel static output)
vercel.json             <- rewrites + function config

Base path: The API serves at /api/* (previously /deckpal/api/* behind a reverse proxy). The SPA serves from the domain root.

Why not individual functions or Hono? The existing SQL is complex (multi-CTE queries, views, generated columns). Rewriting routes gains nothing -- the SQL stays the same. Cold-start cost is acceptable for a collection tracker. Hono migration is documented as a follow-up once the port is stable.

Series calendar dates

The series list and detail routes project first_release_on, and detail set rows project released_on, with to_char(..., 'YYYY-MM-DD') in their SELECTs. SQL DATE values therefore reach the driver, sort comparator, and JSON client as nullable calendar strings. This keeps real sets and upcoming placeholders in descending release-date order, with unknown dates last and name ordering for equal dates, and prevents a release day becoming the previous day in America/Denver after timestamp serialization. The change is limited to the series endpoints: there is no global PostgreSQL parser override. fmtDate renders calendar strings as calendar dates and genuine timestamps as local instants. Actual-route regression coverage uses an isolated query adapter with PostgreSQL OID parser controls, without a live database.

5. Auth and multi-user

Decision: Supabase Auth (email + OAuth) with JWT-based RLS enforcement.

Auth flow

  1. The SPA uses @supabase/supabase-js for auth flows (signIn, signUp, token refresh, OAuth providers).
  2. API requests carry the Supabase JWT as Authorization: Bearer <token>.
  3. API middleware verifies the JWT and extracts the user UUID (sub).
  4. Database queries execute with RLS context set per-request.

Per-request RLS enforcement

The existing SQL is too complex for PostgREST or the supabase-js query builder. The API uses raw SQL via the Supavisor connection pooler with per-request role-switching:

async function withUserContext(userId: string, fn: (client) => Promise<T>): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
      JSON.stringify({ sub: userId, role: 'authenticated' })
    ]);
    await client.query(`SET LOCAL role = 'authenticated'`);
    return await fn(client);
  } finally {
    client.release();
  }
}

For catalog-only reads (search, series list without user progress): the service role is used (bypasses RLS, since catalog tables are world-readable).

Request pipeline order

The createApp pipeline (apps/api/src/index.ts) orders the base-path API router (/api on Vercel, /deckpal/api self-host) as follows:

  1. Pre-auth ingress guard — 600 requests/min per source IP per process, before authentication or RLS. It covers anonymous catalog reads too. Vercel's validated x-vercel-forwarded-for (then x-forwarded-for) identifies the client; outside Vercel, forwarded headers are ignored and the raw socket peer is used. Express trust proxy remains false.
  2. Body-size limits, per route (SEC-08) — mounted immediately after the ingress guard and ahead of authentication, since bounding a body's size needs no identity: named exceptions mounted most-specific-first, then a 100kb default. /bugs 12mb, /client-errors 32kb, /dev/scan-queue and /dev/scan-flags 4200kb (not a bare 4mb — a max-size upload's base64 form is exactly 4mb, with nothing left for its JSON wrapper), /decke 2mb, /lists 2mb, /decks 512kb. Order is load-bearing here, not cosmetic: express.json() no-ops on a request whose body a prior matching parser already consumed, so whichever parser for a path runs first decides its limit — the exceptions must precede the default, which is why there is no longer a single blanket parser on app ahead of everything (that parser used to shadow /register's and /token's own smaller ones the same way). The identity-free /client-errors handler follows its own 32kb parser and the default parser, before authentication and RLS. Every number here is sized in bytes on the wire, not characters, for whatever a caller's OWN JSON encoder does — /decke, /lists and /decks are reachable over the plain REST API (a personal access token, an MCP client, a script), not only this repo's browser client. A character-count cap elsewhere in the codebase (STRATEGY_MAX, RAW_LOG_MAX, …) counts UTF-16 code units; a raw-UTF-8 client costs up to 3 bytes per non-Latin unit, and an ASCII-safe-escaping client (Python's json.dumps default) costs 6. /decke, /lists and /decks are all sized at that ×6 worst case.
  3. Authentication and local identity — authMiddleware verifies a JWT or resolves a PAT. Self-host resolves its single local account before the session limits. The trusted bootstrap check also precedes RLS. Token lookup, local-identity lookup or initialization may use the base/trusted pool here; RLS is not necessarily the first database access.
  4. Session gates and per-user limits — requireSession rejects cloud anonymous callers (401) and PATs (403) for /tokens (20/min), /avatar (10/min), /oauth (30/min), the whole /admin subtree (120/min) and the whole /me/credits subtree (180/min). /bugs (10/hour, SEC-11) sits beside these but carries no requireSession — a PAT, or self-host's resolved local identity, may still file a report — only identity, which this stage has already settled. Credit administration consumes the parent admin budget once; wallet polling has its own budget. Rejection happens before the RLS request connection is acquired.
  5. RLS context — acquire the per-request connection and establish claims/ SQL role. Cloud requests, including accepted anonymous catalog reads, use the existing transaction path. Self-host request transactions are scoped to admin, wallet and OAuth; other local routes retain their prior pool use.
  6. Account/action authorization and handlers — user routes resolve identity, check active-account state, then enforce action permissions in their router and SQL functions. Early rate limiting does not replace authorization.

Ingress, admin and wallet guards use pinned express-rate-limit 8.7.0 with BoundedExpressStore adapting the existing shared 10,000-key process bound. Prefixes keep budgets separate; fixed windows, bounded admission and amortized expiry remain. No skip rules, response-based count refunds or validation suppression are configured. Store errors do not admit the request. These are per-process/function-instance budgets, reset on restart; they are not global distributed quotas. Budget exhaustion returns 429, Retry-After seconds and no-store. Existing token/avatar/OAuth/bugs guards keep their existing implementation and rates.

The rate-limiting steps above cover the ordinary base-path API router only. Three flows are mounted separately on app, ahead of that router:

  • The Stripe raw-body webhook (signature-verified, registered before any JSON parser — re-serialising the body would break signature checks). No application rate limit; Stripe's own retry/backoff and signature verification are the control.
  • The bare-origin OAuth discovery / /register / /token handlers (cloud-only, mint api_token rows). Each now carries oauthPublicRateLimit (SEC-09) — 30/min per source IP, checked before the host allowlist or any body parsing — where previously none of the pipeline above ever ran for them at all.
  • The MCP transport at /mcp (separate api/mcp.mjs function) is not part of this pipeline either, but it now carries two limiters (SEC-09): a global 300/min-per-instance admission counter before resolveToken (deliberately keyed on nothing, not the credential — an unauthenticated caller can mint unlimited distinct credential strings for free, and an earlier version of this fix that checked a per-credential map at this stage let exactly that flood fill the map and lock out brand-new, legitimate credentials too), and a 60/min-per-token budget checked only after resolveToken succeeds, keyed on the resolved, database-verified tokenId rather than the source IP — because hosted MCP connectors (claude.ai and others) call from shared egress IPs common to all of their users, and an IP-keyed limit there would let one heavy connector user exhaust the budget for every other user behind the same IP.

The MCP token/OAuth management endpoints (/tokens, /oauth, /avatar) are ordinary REST routes on the base-path router and use its controls, same as before. See SECURITY.md → Rate limiting and → Body-size limits.

Request identity — the one accessor

The single-user choke point (defaultUserId()) is not removed; it becomes one of two branches behind a single seam, apps/api/src/identity.ts. Routes never learn which deployment they are running in:

const userId = currentUserId(req);   // string, always — the only supported read

resolveIdentity (mounted once in index.ts, ahead of every user-scoped router) settles it per request:

Cloud Self-host
Credential present JWT sub, or the personal access token's owner same
No credential 401 — no fallback exists the single local user (defaultUserId())

The self-host branch is gated on any Supabase environment being absent (SUPABASE_URL, legacy SUPABASE_JWT_SECRET or SUPABASE_MODE), so a half-configured cloud deployment fails closed rather than serving one tenant's rows to anonymous callers. Cloud identity derives from the verified JWT and nothing else.

A second, narrower seam sits beside it for routes that don't require a user at all: resolveOptionalIdentity / optionalUserId() resolve to string | null, where null means settled: nobody, not nobody asked yet. Search, series/set/card pages, the Pokédex and its insights are anonymous-readable; every other per-user route still 401s exactly as before. The null is bound as a SQL parameter, so ownership joins evaluate to UNKNOWN for anonymous rows by the query's own three-valued logic, and the pool runs those requests as Postgres role anon under RLS rather than postgres (which owns every table and bypasses RLS) -- the gap that made this safe to ship (DECISIONS.md 2026-08-10, "The catalog goes public").

The original pivot instead rewrote ~40 call sites to req.user!.id. That is correct in cloud and silently undefined in self-host, where authMiddleware is deliberately a no-op — see DECISIONS.md 2026-08-10. currentUserId() returns string or throws, so there is nothing for ! to assert; a pure test (__tests__/identity.test.ts, run in CI via test:auth) exercises all three branches without a database and fails if a route reaches for req.user again.

6. RLS schema model

Classification

Every table is catalog (shared), per-user, or a public profile table:

  • Catalog tables (card, series, card_set, card_variant, price_current, price_observation, sync_run, image_asset, etc.): world-readable, service-role- writable. No user_id column. RLS policy: SELECT: true.
  • Per-user tables (collection_item, deck, deck_card, deck_version, battle_log, card_list, list_item, binder_placement, user_settings, etc.): readable/writable only by the owning user. RLS policy: user_id = (SELECT auth.uid()). A child row's foreign key includes user_id ((deck_id, user_id) REFERENCES deck (id, user_id)), because a foreign-key check ignores RLS and would otherwise let a user attach rows to someone else's parent (migration 072).
  • Public profile tables (user_profile, user_showcase): world-readable for the /u/{name} profile page, writable only by the owner. user_profile is writable only in its avatar columns, and an avatar key can belong to one profile at a time (migration 072).

The user ID migration

app_user.id transforms from BIGINT GENERATED ALWAYS AS IDENTITY to UUID REFERENCES auth.users(id). Migration 020 handles this type change across all FK columns. Migration 021 (Supabase-only) adds the FK to auth.users, enables RLS on all tables, and creates the policies.

The (SELECT auth.uid()) wrapper (rather than bare auth.uid()) is deliberate: benchmarked at ~95% improvement because the query planner treats the subselect as a constant.

Views

A Postgres view runs with its owner's rights unless it is created WITH (security_invoker = true), and the owner here is the migration role, which owns every table and so never meets their RLS. A plain view over a per-user table therefore publishes all of it to anyone Supabase's default grants let SELECT the view, which includes the anon key.

That is exactly what collection_dupe_predicate did. This section used to say it "reads through the RLS'd collection_item table and works correctly"; it did not. From migration 020 until 072 it served every account's (user, card, owns-two-or-more) rows at /rest/v1/collection_dupe_predicate to anyone with the anon key (DECISIONS.md 2026-09-26). Nothing read it, and 072 dropped it.

Every remaining view (variant_tier_resolved, master_required_variant, set_variant_coverage, card_without_standard_variant, and the service-only admin_user_role) is security_invoker since 072, so a view applies the caller's RLS like the tables beneath it. Two checks hold that line: packages/db/src/__tests__/migrationLint.test.ts fails any migration that creates a view without the option, and the database integration suite (apps/api/src/__integration__/reach.mjs) applies every migration and asserts that anon and a second user reach none of a user's rows in any table or view.

7. Image storage

Cloud: Supabase Storage

Bucket: card-art (public)

Path contract (preserved from the disk cache):

card-art/images/<lang>/<serie>/<set>/<localId>/low.webp
card-art/images/<lang>/<serie>/<set>/<localId>/high.webp
card-art/sets/<setId>/logo.webp
card-art/sets/<setId>/symbol.webp

Provenance choke point: All writes go through a putAsset() wrapper (packages/storage/src/put-asset.ts) that uploads to Supabase Storage via the service role and upserts the image_asset row with provenance. The contract is identical to the disk-based version: no direct writes to the bucket, every byte has an image_asset row. provenance is a required argument.

image_asset records the asset's identity and provenance — shared across tiers, because where bytes came from does not change when you copy them. image_object (migration 025) records one row per physical copy, keyed (cache_key, tier) with tier IN ('disk','object'), holding that copy's byte_size, content_type and storage etag. The two copies genuinely differ: TCGdex re-encodes between the day the disk cache was warmed and the day the bucket was filled. Each choke point writes only its own tier, and image_object.cache_key is a foreign key to image_asset, so a stored copy of something with no provenance record cannot be represented at all. manifest:check --object-store reconciles the object tier against a listing of the real bucket, which is what makes the "every byte has a row" claim falsifiable on the cloud side rather than self-reported.

CDN: the API still emits the same relative paths self-host uses (/deckpal/images/en/<serie>/<set>/<localId>/<low|high>.webp, built by cardImages() in apps/api/src/db.ts and setAssetUrl() in apps/web/src/components/ui.tsx), and vercel.json still rewrites that prefix to /api/images?p=…, a serverless function (apps/api/src/images/handler.ts) that lazily fills the bucket on demand: a MISS reads the image_asset row, fetches the bytes from its source_url (or, for card art, the canonical derivation of the request path), writes them through putAsset(), then 302s to the public object URL; a genuine FAIL serves the same ~1 KB placeholder self-host uses, or 404 for set imagery. An image URL never answers with HTML -- the invariant that closed the bug where every <img> on deckpal.app was silently serving the index shell (DECISIONS.md 2026-08-10, "Cloud image tier: lazy cache-on-demand out of Supabase Storage"). Image transforms (resize, format conversion) are available on Pro.

The fill has a destination control, not only a content check. The bytes for a MISS come from image_asset.source_url, and packages/storage/src/upstream.ts holds the allow-list of hosts that URL is permitted to name — assets.tcgdex.net and raw.githubusercontent.com, the only two any code path derives. It is enforced with redirect: 'manual' and re-checked on every hop, because a check that only ever sees the first URL is bypassed by a 302; the resolved addresses are checked too, so an allow-listed name pointing at loopback or 169.254.169.254 is refused. The request is rebuilt from the allow-list's own constant origin, so the host we actually open a socket to is selected rather than carried through. That sits alongside the older content check (image content-type plus magic-byte sniff), which exists for a different failure — assets.tcgdex.net answering 200 text/html for an asset it does not have. One is about the body, the other about the destination; neither substitutes for the other. The object KEY is likewise checked at each exported Storage function rather than only at parseImagePath, so the bulk paths that address the bucket from database rows get the same guarantee as a request does (packages/storage/src/object-path.ts). Both were added on 2026-08-27 to close GitHub issue #96; SECURITY.md records what they do and do not cover.

But on the cloud the SPA no longer asks the function first. Since DECISIONS.md 2026-08-26 the browser addresses the public object URL DIRECTLY (apps/web/src/lib/cardArt.ts, applied by CardImage, SpriteTile, SetLogo and SetSymbolTile), deriving it with the same parseImagePath the tier itself parses requests with, imported from the @deckpal/storage/paths subpath export. The relative path is a pure function of the request path (B6) and the bucket is public, so the function and its redirect are simply not needed for an asset that is already there.

That matters because a HIT was never free: the function still had to be invoked and still probed Storage before answering 302, so every tile cost a serverless invocation plus two sequential round trips. Measured on production before the change: 89-320 image requests per page, 100% of them 302s, card art arriving at p50 1954 ms / p90 4154 ms / slowest 12.6 s.

The proxied path remains, as the fallback. A cold or genuinely absent object fails the direct request, CardImage retries through /deckpal/images/…, and the lazy fill runs exactly as described above -- so nothing lost the ability to self-heal, and self-host (no Supabase URL, so no direct base) is untouched. Art is fetched crossorigin="anonymous", which the bucket allows, so the service worker caches CORS-readable responses rather than opaque ones the browser pads against the origin's storage quota (apps/web/src/sw.ts, cache deckpal-img-v2).

Warming is a first-class step, not a side effect of traffic. pnpm --filter deckpal-images warm:cloud (apps/images/src/cloudWarm.ts) drives the tier's own fill across the whole catalog from the public API, with no credentials. Before it existed the bucket held only what someone had happened to look at -- 18,840 of 21,066 cards had no object at all -- because every other warmer targets the self-host disk cache and storage:backfill only mirrors one that already exists. Run it after a catalog import or a set release.

A second public bucket, user-avatars, holds profile photos independently of image_asset/putAsset() -- the record lives on user_profile (migration 029) instead, keyed by a random 32-hex object name rather than anything derived from the user id, and re-encoded server-side to 256×256 WebP (DECISIONS.md 2026-08-10, "Profile photos").

A third bucket, dev-captures, is private: the scanner's and labeler's captures (dev-flags/ labels, flagged frames and scanner reports with their sidecars; dev-queue/ pending labeler photos). The API creates it on first use and reaches it only with the service key (packages/storage/src/capture-store.ts); the browser gets the bytes through the labeler-gated /dev/scan-flags and /dev/scan-queue routes, never by URL. Until 2026-09-28 these prefixes sat in the public card-art bucket; capture-migration.ts moves what is left there (copy, read back and compare, then delete), driven from the labeler pages via POST /dev/scan-queue/migrate-captures. See SECURITY.md, "Scanner and labeler captures are private".

Self-host: local disk cache

Unchanged from the original design. apps/images/src/store.ts is the write choke point. Card art lives at <IMAGE_CACHE_ROOT>/images/<lang>/<serie>/<set>/<localId>.<low|high>.webp. apps/images serves the cache. apps/images/src/layout.ts is authoritative for all paths.

Storage cost

Supabase tier Storage included ~1.9 GB image corpus
Free 1 GB Exceeds free tier
Pro ($25/month) 100 GB Included

The free tier cannot hold the full image cache. Options: start on Pro (recommended), serve only low-res images on Free (~400 MB), or defer image migration and serve from upstream CDNs.

8. Data ingest and sync

Catalog -- direct import, never the TCGdex API

The weekly catalog job pulls the published container image, streams docker save through tar, extracts generated/en/{cards,sets,series}.json and imports it. No container is ever created. The TCGdex API server must never be run -- it loads all 18 languages into memory per worker (measured 6.4x JSON-to-object expansion) and would OOM most environments.

Prices -- daily, and the UI must say so

The TCGplayer feed comes from TCGCSV, once daily. Cardmarket is likewise daily. Every price in the UI renders "as of {date}" -- honest by construction.

The same honesty applies backwards in time. Old history is not daily -- it is weekly and then monthly OHLC buckets, because daily-forever does not fit the disk. So the price chart draws each period's CLOSE as a line with its low-to-high range as a band, and captions which stretch is which grain ("daily since Jul 30 · weekly to Feb · monthly before"). A chart whose left half is smoother than its right half, with nothing saying why, reads as the market having calmed down.

Sync jobs -- GitHub Actions

The existing importers (apps/sync/src/) are idempotent, resumable, and use exactly 1 connection each. They run unchanged as GitHub Actions scheduled workflows with the Supabase connection string. Docker is available on GHA runners for the TCGdex catalog extraction.

Sync invariants

  • Skip-if-unchanged is the first step of every job, gated on the upstream's own stamp.
  • captured_at is the source's timestamp, not now(). Re-running a day's sync is a genuine no-op.
  • One transaction per group, a resumable cursor, and a pg_advisory_lock so a manual run cannot race the scheduler.

9. Self-host story

The repo must work with plain Postgres + no Supabase. The Supabase integration is an additive layer, not a hard dependency.

Migration split

The split is a per-file marker, not a numbered range: a migration whose first line is -- @supabase-only is applied only when SUPABASE_MODE is set, and skipped automatically otherwise (packages/db/src/migrate.ts). The marked files are the Supabase-integration migrations -- 021's RLS policies + auth.users FK and the later per-feature RLS/Storage companions -- while a feature's own schema migration (e.g. 022 bug_report) is plain Postgres and runs everywhere.

Self-host deployments run every unmarked migration and skip the -- @supabase-only ones. Auth is handled by the reverse proxy. Images are served by apps/images. Sync jobs run via cron or any scheduler.

10. The agent tool layer — one definition, two front-ends

packages/agent-tools (@deckpal/agent-tools) is the single definition of what an agent may do in DeckPal. 28 tools (17 read, 11 write), plus three capability-gated decke_improvement_* readers served only over MCP, each a ToolDefinition: a zod input schema, annotations (readOnlyHint is required in the type, not optional as MCP's own SDK has it — a tool that forgets to state it fails to compile rather than defaulting into whatever reads the flag), and a handler written against Ctx alone ({ db, api, userId }) with no protocol details in it. Reads go straight to Postgres; writes and all deck/list operations go through deckpal-api on the same host (apps/mcp/SPEC.md §3), so write logic — upsert, collection_event append, recomputeSetProgress, one transaction — stays defined exactly once in apps/api/src/routes/*, however many surfaces call it.

"Destructive" means two things here. A definition's destructiveHint is DeckPal's meaning, "can destroy data that is not otherwise recoverable": the three deletes and revert carry it, and Deck-E's approval rule keys on it, asking before every such call, previews included. MCP's destructiveHint is broader (anything but a purely additive write) and defaults to true when omitted, as openWorldHint does. The MCP adapter (wireAnnotations in apps/mcp/src/adapters/mcp.ts) therefore advertises DeckPal's flag OR the definition's overwriteHint, explicitly on every write, openWorldHint: false everywhere, and mirrors the title into annotations.title for clients older than protocol 2025-06-18 — so an MCP client sees 10 of the 11 writes as destructive while Deck-E still asks only before the four. The adapter also drops zod's $schema line from advertised schemas (2020-12 is MCP's default dialect) and cuts any single result over 100,000 characters with a visible note; that is a backstop, since the list tools page on their own. pnpm mcp:audit measures tools/list and every read's result against a seeded database, with the budgets enforced in the database CI job, and apps/mcp/TOOLS.md is generated from the registry by pnpm mcp:tools-doc.

card_price_history (added 2026-09-12) is a read-only OHLC price-history tool that delegates to the existing GET /cards/:cardId/prices endpoint through ctx.api.get — no schema change, no new credential, no direct Postgres touch — and carries the endpoint's verbatim MAY/MAY NOT rollup interpretation contract in its description. It is registered through catalogTools/allTools, so both adapters serve it; get_card is unchanged and still returns current prices.

card_price_history accepts a tool-side offset (nonnegative safe integer, default 0). Each invocation makes one unchanged REST request and returns a text page of at most 5500 characters, including headers and continuation, with complete OHLC records and their printing identity. Model-visible next_offset=<integer> resumes the same card/range/currency; next_offset=none ends the traversal. This keeps each page below conversational Deck-E's 6000-character limit and also works through MCP's text-only response. The global adapter limit and REST API are unchanged; empty history and an offset past the available records are distinct results. Offsets count points in API order, plus one record for each empty variant. A record whose complete fields and identity cannot fit the budget produces an explicit error.

Two cross-cutting flows added 2026-08-29 (the agentic pass): card rules text — get_card renders abilities/attacks/effects/matchups from the migration-003 tables, making the catalog the grounding source for any rules claim on either front-end; and battle-log deck inference — add_battle_log with no deck_id calls POST /decks/log-preview (parse + score the log against every deck's current-version list, apps/api/src/deck/battlelog.ts's scoreDeckMatch) and returns ranked candidates as a pure read; the write happens only on a second, explicit call. With a named deck, its preview calls POST /decks/:id/logs with dryRun: true, which runs the insert's deck resolution, parse, owner identification and field merge without writing, so the approval card can surface the refusal the eventual write would produce. A pasted log is extracted whole from its Setup/turn anchor through its closeout (including reverse order and tolerated unknown client lines); if a healthy first leg neither logs it nor raises its card, a bounded paste backstop ranks decks and raises that signed card. See 2026-10-10-deck-e-logs-a-pasted-battle-every-tool-in-view-from-step-one-a-backstop-the-whol.md.

Two adapters translate that one definition into a protocol:

  • apps/mcp/src/adapters/mcp.ts — the only file that knows the tools are being spoken to over MCP. registerAllTools(server, ctx) walks allTools() and calls server.registerTool(...) per tool, translating ToolResult to MCP's CallToolResult. This is where "Registration: server.registerTool" now lives; the tool modules themselves import nothing from the MCP SDK.
  • apps/api/src/decke/adapters/aisdk.ts — the AI SDK's tool(), one per definition, wrapping the handler so it cannot throw into the stream (a thrown tool kills the turn; a returned error is a sentence the model can react to) and clamping output to a character budget for the chat tier only (DEFAULT_MAX_TOOL_CHARS, §15c). Read §15c for how Deck-E calls it.

A tool added or changed for Claude over MCP appears for Deck-E in the same commit, and vice versa — there is now only one place a tool can be added. apps/mcp's own tool modules were proved behaviour-preserving three ways when they moved (byte-identical tools/list JSON-RPC response, a static dump of every tool's schema, and a whitespace-insensitive diff): see DECISIONS.md 2026-08-21, "One definition of what an agent can do in DeckPal". The one deliberate change made in that move: search_cards, get_card and set_progress now append a series slug to their output (a trailing field, no row reordered), because the set route needs one and no slug is derivable from a set's name.

MCP server — live and multi-user

deckpal-mcp's 28 tools are served to any signed-up user at https://deckpal.app/mcp (apps/mcp/src/cloud.ts), authenticated per-user by a personal access token (dsk_…, SHA-256 hashed, shown once at creation, revocable from Profile). Each call resolves the token to a user_id and runs inside the same withUserContext per-request RLS transaction the REST API uses, so a tool has two independent locks: its own WHERE user_id = $1 and migration 021's RLS policies underneath it. Self-host is unaffected and keeps the original single-user x-brain-key process (apps/mcp/src/index.ts). Cross-user isolation was verified against production with a throwaway account: all ten hostile writes against another user's real ids failed closed, and read tools reported zero owner data (DECISIONS.md 2026-08-10, "MCP goes multi-user").

Connecting without a manual token. As of 2026-08-10 the token above can also be minted automatically: a real OAuth 2.1 authorization server (apps/api/src/oauthServer.ts for the public RFC 7591 dynamic-client- registration + RFC 6749 token-exchange endpoints, apps/api/src/routes/ oauth.ts for the session-gated consent decision, apps/web/src/routes/ Authorize.tsx for the /authorize consent screen) sits in front of the same credential. Any MCP-spec client (claude.ai, ChatGPT, Gemini, Claude Code) adds just https://deckpal.app/mcp and chooses "Connect"; the client self-registers, the user signs in and approves a consent screen naming the client and its exact permissions, and the token endpoint mints an ordinary api_token row — the OAuth layer is a bridge onto the pre-existing credential, not a second one. cloud.ts's 401 responses advertise the flow via WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth- protected-resource" so a compliant client discovers it automatically (DECISIONS.md 2026-08-10, "a real OAuth 2.1 authorization server for /mcp").

Since migration 075 (2026-09-26, security audit SEC-07) that row is a connection whose secrets rotate beneath it in oauth_token: a one-hour access token and a single-use 90-day refresh token, resolved through the row so revocation, suspension and expiry are decided in one place (packages/db/src/grants.ts). The consent screen names the redirect's host rather than the app's self-chosen name, and a connection may be read-only, enforced at the MCP edge (read tools only, BEGIN READ ONLY) and at the REST API (enforceTokenScope). Tokens from before 075 are unchanged. When a newer web app reaches an older API, missing trust on GET /oauth/client limits the consent screen to full access because the older API ignores scope.

The battle simulator — packages/sim (2026-10-10)

packages/sim (@deckpal/sim) is a pure, seeded Pokémon TCG rules engine, written new in TypeScript (no reusable engine had current Standard cards and the properties below), with no I/O. Games are played by a CPU on both seats, not an LLM: the assistant reasons over the numbers and never chooses moves.

  • The engine is the only judge of legality. It is (state, choice) → new state + events; the pilot can only pick from the options it lists.
  • State is plain JSON data, half-finished effects included. A card effect is a compiled clause program, and one paused mid-way is a frame (code, pc, vars) inside the state, so a game can be cloned for search, saved and replayed.
  • One decision shape: pick min..max of a numbered list, and the list is the legal-move list.
  • Seeded streams in the state (one per deck, one for coins): one seed reproduces a game. Every change is an event in the battle_events vocabulary, so a simulated game and a parsed Live log are the same kind of stream.
  • Cards are data, scripts over a reviewed clause vocabulary (src/dsl.ts), keyed by printed text, so every reprint with identical text resolves to one script and a reworded printing never silently matches. Frames are snapshotted from the catalog so tests stay pure. An unscripted Pokémon runs on printed damage only; an unscripted Trainer or Special Energy cannot be played, and every report names both. The 13 decks in the owner's account are scripted.
  • The CPU never reads hidden information. makePilot('search') searches its own turn under a hand-tuned evaluation, with exact last-Prize checks, on determinised copies in which everything it cannot know is resampled.
  • Runner and report. The batch runner plays PAIRED games (one seed, seats swapped, so each deck goes first half the time; the two deals differ) and reduces each game to a summary off the event stream. stats.ts and report.ts turn those into Wilson 95% intervals with n, draws and time-outs apart from wins and losses, and a text of at most 5,000 characters that says "simulated, not real" on its first and last line. Nothing is stored and simulated results are never merged with real battle logs.
  • Where it runs. apps/api is the only caller: POST /decks/simulate (routes/deckSimulate.ts, 6 calls a minute per account) plays a saved deck or an unsaved list against up to 8 of the caller's decks inside the existing 60 s /api function, with one 25 s budget for all matchups (under the RLS middleware's 30 s connection hold). The read-only simulate_battles tool in packages/agent-tools calls it for both Deck-E and the MCP server. @deckpal/sim is built before the API in CI and scripts/vercel-build.mjs.

Its sibling deck_odds is draw math only (opening hands, Prizes, cards seen by turn N): a seeded Monte Carlo in apps/api/src/deck/odds.ts behind POST /decks/odds, plays no cards, and answers in the same shape — method, sample, seed, margin. Together the two bring the catalog to 27 tools (16 read, 11 write).

Plan, rules calls and the accuracy checks: roadmap/plans/battle-sim/PLAN.md. How a card is implemented (gap analysis → vocabulary review → script from printed text → scenario test → round-trip → rulings citation): packages/sim/CARDS.md. Decisions: 2026-10-10 "Battle simulation first", "Battle simulator: simulate_battles for both assistants" and "Deck odds: a draw-math tool for both assistants". Deferred on the owner's say-so: match play on a board UI, Deck-E as an opponent, difficulty and personality.

11. Correctness traps that shape the design

These are verified findings that a reasonable implementation would otherwise get wrong. They are listed here because each one is silent when wrong.

Trap Consequence if missed
TCGdex Cardmarket *-holo fields mean reverse holo, not holo finish Wrong prices sitewide, no visible error
legal.standard is per-print; reprints confer legality Deck validator rejects most real decks
dexId[0] does not follow the card name on multi-species cards Wrong species captured
4 Trainer cards carry dexId Trainers silently capture Pokemon; gate on category='Pokemon'
TCGdex uses both apostrophe codepoints for Farfetch'd Naive join drops half the prints
PTCGL section headers are line counts, not card counts Importer rejects valid decks
assets.tcgdex.net 200s with HTML for unsupported extensions Cache fills with garbage
TCGdex card(id:) prefix-matches; localId padding is inconsistent Wrong card returned; join numerically
No upstream English-image fallback exists (hard 404) Must be application logic

Executable boundary checks

Three independent PR/main workflows cover pure/build checks, disposable PostgreSQL integration, and browser/deployment behavior. The database runner owns a private Unix-socket-only cluster from initdb through pg_ctl cleanup; it accepts no existing database, refuses repository .env before application imports, and replaces inherited connection settings. Its focused fixture schema drives the actual series/prices routes, shared history tool and conversational adapter in UTC and America/Denver. It does not validate the entire migration history or production RLS setup.

Browser checks build the actual SPA at cloud / and self-host /deckpal/, use deterministic local API fixtures, and exercise real chat components in a separate test-only Vite entry. Desktop/390px screenshots and result artifacts make the behavior reviewable. Deployment checks derive announcement assets from source metadata and check tracking/upload filters with negative controls. These suites neither use the live development proxy nor load repository .env files; they do not deploy or access production. The existing production-targeting collection suite remains manual and excluded from CI.

12. Data model

Full DDL, indexes and worked SQL: research/SCHEMA.md.

Variants are the atomic unit. card_variant is the ownership, pricing and buy-link row. The natural key is (card_id, variant_kind_code).

The taxonomy is an open enum implemented as data. A variant_kind lookup table carries typed facet columns decomposed from variants_detailed, so a new stamp is one INSERT and never a migration.

Set progress is split by scope: single-set derived on read; all-sets materialized into user_set_progress.

Price history is append-only and partitioned, keyed (variant, source, currency, captured_at) with captured_at from the source — and TIERED BY AGE: daily rows for ~30 days, then weekly OHLC buckets for ~6 months, then monthly buckets forever (price_bucket, migration 048). Daily forever is ~6.6 GB/year against an 8 GB allowance; the tiers are ~2.9 GB steady state. The rollup verifies every bucket against the source it is about to destroy, and drops a partition only one run after retiring it. research/SCHEMA.md §7.5.

user_id is on every user-owned row; catalog and pricing tables are global.

Billing — a cache in front of Stripe, and a schedule for one question

billing_account (migration 053) is one row per account and is not a licence. There is deliberately no entitlement column: the hosted tier is pay-what-you-want with $0 a valid permanent answer, and nothing in the product reads this row to decide what somebody may do.

It holds two unrelated things, which is why the table has a divider in it:

  1. A cache of Stripe. Amount, status, period end, and a card's brand/last four/expiry. Stripe is the source of truth; if the two disagree, this row is stale and stripe_synced_at says how stale. No money is ever computed from these columns — they exist to render a page without a round trip to Stripe on every profile load. There is no amount_owed, no paid_through and no invoice history: invoices live in Stripe and are reached through its portal.
  2. When to ask again. visit_count (sessions, not page loads — at most one per six hours, counted in SQL), prompt_last_shown_at, onboarded_at. This is per-ACCOUNT rather than per-device on purpose: clearing site data must not restart the cadence, and signing in on a phone must not re-ask what was answered on a laptop.

Writes have two paths and both are narrow. The Stripe webhook runs outside the RLS middleware, as the connection's owning role, and writes by customer id. The billing routes run as authenticated and write through four SECURITY DEFINER functions that derive the row from auth.uid(). The alternatives — a second pooled connection per request, or a RESET ROLE dance on the request's own client — are respectively a B2 violation and a way to leave a whole request running with RLS off. See SECURITY.md for why the customer id in particular has to be unwritable from the browser.

The connection budget shapes the Stripe client. These are the first routes that hold the per-request pooled connection across long third-party I/O, and the watchdog reclaims it after PGRLS_MAX_HOLD_MS (30 s). The Stripe client is therefore sized against THAT rather than against Stripe: 8 s timeout, one retry, so a single call's worst case stays inside the budget with room for the database writes either side. When a request does fail after a charge has succeeded, the error says "check your profile before trying again" rather than "nothing was charged" — the latter is how a one-off gets paid twice.

billing_ab_event (055/057/058) is an append-only log, not a counter. It carries one row per exposure, answer and dismissal, with the experiment arm STAMPED on the row rather than joined — so a reassignment could not rewrite the experiment's own past. It exists to answer one question: does offering a $1 rung raise revenue, or move people down the ladder? The reading is revenue-per-exposure, not conversion rate, and the query has three traps in it (amount changes append rows, the testing override records, one-offs are a different kind); the corrected version lives beside promptDue in billing/store.ts, because migration 055's own header predates all three and cannot be edited (B4).

One-time contributions reuse the subscription's card path exactly — same SetupIntent, same Payment Element — and charge server-side against the customer default. Two card-collection flows would mean two places for the Link prefill to drift and two ways for "your card never touches DeckPal" to stop being true.

The subscription has no Price object. A pay-what-you-want amount cannot be a fixed Stripe Price, so each subscription item carries inline price_data against one Product (STRIPE_SUPPORT_PRODUCT_ID). Every invoice then reads "DeckPal Support $5.00/month" and the dashboard still groups supporters under one product — unlike the quantity-of-dollars trick, which invoices as "25 x $1.00".

Card scanner — the table is the index

A photo becomes a 64-bit dHash; the catalog's ~22.7k hashes live in card_image_phash; the match is one SQL query that ranks them by Hamming distance. There is no in-process index and no vector extension:

LEAST(bit_count(hash_bits # probe.p0), …, bit_count(hash_bits # probe.pN))

bit_count is native Postgres 14+. hash_bits is a generated bit(64) mirror of the bytea hash — the hash is stored as bytea because that round-trips to a JS bigint, but Postgres has bitwise XOR only for bit, and converting per row per probe costs 3x (190 ms vs 64 ms measured). Query time on the live index: 22.6k rows × 34 geometry probes, ~69 ms of server time.

Accuracy is measured, not assumed: 60 cards × 7 realistic degradations (389 scans -- re-encode, JPEG noise, tilt, off-centre, keystone, dim/glare) against five synthetic no-card frames. Threshold 9 admits 96.9% of correct scans while rejecting every no-card frame tested. ALGO is dhash8v3 -- bumped from the ImageMagick-era pipeline, whose hashes land 0-9 bits away (median 2) from sharp's, enough to invalidate the old index outright. The old ~99.6% figure was measured on that superseded pipeline against a different degradation set and does not carry over (DECISIONS.md 2026-08-10, issue #20).

Two consequences worth stating plainly. An indexer run is live immediately — no restart, on either deployment. And index-time and query-time hashing must be one pipeline: the same decoder, the same resample. Both sides run sharp, which is also why they can: a serverless function has no system ImageMagick, and the shelled-out decoder the scanner shipped with is exactly what made the hosted scanner match nothing (issue #20). ALGO names the pipeline; changing either side means bumping it and re-indexing.

Card scanner — the identity embedding (built, off by default)

The hash above is a good prefilter and a bad identifier. Measured 2026-09-03 on 19 photographs of real cards rectified from HAND-LABELLED quads — the best crop geometry this pipeline can produce — its top-1 was the right card twice, and its own matched: true gate fired four times and named a different card every time. 0-for-4 precision. The failure is not crop quality and cannot be tuned away: a 64-bit greyscale hash cannot bridge glare, colour cast and sleeve reflection to a clean catalogue render.

So identity becomes an image embedding, per the owner's 2026-09-04 ruling — computed server-side, per the 2026-09-05 amendment ("it's ok if we run that server-side"). The phone uploads the rectified crop it already uploads and downloads no model at all; apps/api/src/scan/queryEmbed.ts runs the checkpoint over the same input spec at the other end. The runtime is the ORT-web WASM bundle the detector already vendors, read off disk — not onnxruntime-node, which unpacks to 296 MB against a 250 MB function ceiling and whose binding dlopens a shared object no static tracer can see (measured 2026-09-06; DEPLOYMENT.md carries the bundle arithmetic).

The shape, and the four places it is enforced rather than intended:

  • One versioned input spec. packages/matching holds it, in TypeScript and in a Python mirror that produces BIT-IDENTICAL tensors, checked by both test suites against one committed golden. The phone, the API and the catalogue job all import it, because a catalogue vector is computed months earlier on a different machine and a cosine between two different resamplers measures the resamplers. The spec does NOT redeclare the detector's PIPELINE_VERSION — apps/web/src/scan/engine/frame.ts owns that, this formats what it is handed, and a test asserts the number appears nowhere in the module.

  • A stamped index. card_embedding (migration 051) is keyed on (card_id, quality, stamp) where the stamp names the spec version and the checkpoint, and the matcher filters on it. This is card_image_phash.algo's rule, extended so two model generations can coexist and a cutover is a change of one string. The HNSW index is partial on the current stamp, because an unfiltered one would pick a neighbour from the wrong generation and let the WHERE drop it afterwards — fewer than k rows, with nothing in the plan to say why.

  • Two confidences, never one. POST /api/scan/embed returns an identity block and a variant block and deliberately nothing that could be mistaken for their average — no matched, no confidence. Variant has exactly one level today, unknown, because nothing in this build measures a printing; what it does compute is whether that unknown blocks the commit, which is the ruling's "no silent default-to-primary" as a field the UI cannot ignore.

  • One resolver, with the vector as evidence inside it. The 2026-09-06 ruling — "ensure that the image vector match is still a point of data in the match" — means the embedding is a rung of the OCR ladder (apps/api/src/scan/resolve.ts) and not a rival endpoint's verdict. Three rules, in fuse.ts: a printed key that resolved is never reviewed by the vector alone (OCR wins a disagreement, because (setCode, number) is unique across 20,444 cards with zero collisions and a cosine is not that kind of claim). Since 2026-10-10 a key resolved on the NUMBER is questioned when the printed NAME agrees with a different card that the vector, or the name plus the denominator, also names, because a dropped digit can land on a real card (letTheNameQuestionTheKey in resolve.ts). Where a key narrowed the world to candidates it cannot choose between — 014/198 is Steenee or Floragato — the vector's own top-1 agreeing with one of them makes the answer confident and resolvedBy: 'corroborated'; and alone, the vector needs the calibrated gate and is the LAST rung that can name a card. The perceptual hash is demoted by the same ruling to a near-exact band (distance <= 2) in which it may CONFIRM the vector and may never speak alone — its measured wrong top-1s are same-art reprints at distance 1-6, inside that very band. scan_exemplar records resolved_by and phash_distance so "does the hash ever change an outcome" — the question that decides its retirement — is a GROUP BY.

The verify flywheel (migration 052) stores embeddings, not photographs: 2-3 frames per confirmed scan, each with its own vector, plus an optional sleeve label and the matcher's own claim at the time — which makes "of the scans we called confident, how many did the reader change" a continuous measurement rather than an afternoon with a spreadsheet. Crop retention is a separate opt-in tier whose consent is enforced by a trigger, so a retained image with no recorded consent is unrepresentable rather than discouraged.

The whole path is behind SCAN_EMBED_MATCH, unset by default, and the hash path is untouched. Turning it on additionally requires migration 051 applied, tools/embed-catalog run, and the checkpoint present at SCAN_EMBED_MODEL_PATH — none of which the API can check, which is why /health reports scanEmbed and the boot log says so. With the flag off, /resolve returns byte-for-byte what it returned before any of this existed, which a test asserts across every fixture case rather than a comment claiming it; with the flag ON and the index still empty, it returns the same thing again, so the migrate → embed → flag sequence has no step that changes answers early.

Card scanner — voice commands (opt-in beta)

The reader can talk while the scanner runs ("that one's a reverse holo", "two of those", "remove it") and the list corrects itself (apps/web/src/scan/voice/, the scanner voice entries in decisions/2026/). It lives entirely in the browser, on the Web Speech API. The server sees no audio and no transcripts. Its only part is the scanner_voice feature-catalog row that gates the control (migration 073). The browser's own recognizer does send audio to its vendor (Apple for Safari, Google for Chrome). The structure is four pure modules and one hook:

  • grammar.ts is a closed grammar matched with a phonetic edit distance, plus a coverage gate so conversation is ignored. It reserves the longest scanned catalog name, including literal and spoken numeric aliases, before counts. A competing shorter name plus a valid count refuses the whole command rather than guessing which card was meant. It validates remaining whole numeric tokens with one count reader before punctuation is removed, refusing any count it cannot apply exactly within 1–99. Subject resolution is independent of the operation: a second capture or an unclear second subject refuses the entire count, printing or removal command.
  • printings.ts maps a spoken printing onto the card's real variant kind slugs.
  • actions.ts turns a command into a pending action on a row (the row id is the capture id, so a command can wait for a card that is still being identified), applies it after a hold and undoes it.
  • recognizer.ts hides the difference between Safari's continuous sessions and Chrome's session per utterance. It also runs the watchdog for the iOS recognizer's silent death.
  • useScannerVoice.ts owns time, React state and the page lifecycle.

Before Verify opens, pending speech changes settle against the current row. Unfinished speech and failures from proposal through timeout, unidentified landing and application are stored in the voice queue, independently of captions. They survive Verify, later commands and Undo until explicitly acknowledged. Both Add and the unresolved-row confirmation pass the same warning gate before a collection write. The bounded warning list scrolls while acknowledgement stays reachable. The caption, pending chips, screen-reader announcement and Verify warnings carry the bug reporter's screenshot exclusion marker. A report can show the scanner without saving spoken text.

Targeting snapshots both named captures and “that one” at the first words. Structural phrases match normalized token arrays, while exact names reserve boundaries before fuzzy printing/name matching. Fuzzy windows also preserve structural tokens, including trailing objections. Distinct names tied for a match or recognizer alternatives that disagree on the target are refused. Identical names resolve by capture time, not row arrival order. Gender signs and other identity-bearing symbols remain distinct, with exact matches required for those names. The full rules and test matrix are in scan/voice/README.md.

Nothing in the scanner may play audio, because any playback silently kills the iOS recognizer.

13. Frontend

Built against UI Spec (wiki). Full rationale: Frontend Research (wiki).

Stack: React 19, TanStack Router, TanStack Query, TanStack Virtual, Vite, Tailwind 4. Charts via d3-scale/d3-shape; drag-and-drop via @dnd-kit (both lazy-loaded).

Offline is tiered, and honest about phones. Tier 0: app shell, ~5-6 MB, always. Tier 1: visited art, LRU-capped ~2,000 images. Tier 2: opt-in pack (owned cards + tracked sets). Offline means full metadata browse, search and edit everywhere; real art for what you own.

Nothing may block first paint without a deadline, and the auth session is the one that did. supabase.auth.getSession() reads localStorage only while the stored access token is more than 90 s from expiry; inside that margin — and on every cold load after the token has expired — it refreshes over the network first, and @supabase/auth-js attaches no AbortSignal and no timeout to that fetch. So a request that never SETTLES (a socket stranded by a network change or a sleep/resume, a captive portal, a stalled H2 connection) held it open forever, and three places awaited it before anything could render: main.tsx's index route in beforeLoad, AuthGuard, and api.ts's authHeaders() before every single request. The visible result was issue #75 — a blank dark page, or the public catalog as chrome with no content — and the inline "Loading DeckPal" state could not cover it, because React's first commit had already replaced it.

lib/sessionDeadline.ts bounds the read; lib/authSession.ts is the only module allowed to call the client directly, and apps/web/scripts/check-auth-deadlines.mjs fails the build if a raw auth.getSession() / auth.refreshSession() reappears anywhere else — a bound one call site can opt out of silently is not a bound. A timeout means UNKNOWN, never "signed out." Every caller falls back somewhere non-destructive (/ routes to the public catalog, AuthGuard says "still checking", a request goes out unauthenticated and takes a finite 401 rather than waiting), and a late answer settles the UI through onLate with no reload. Behind all of it, an inline watchdog in index.html replaces an unexplained blank page with a message and a Reload button after 12 s whatever the cause — that one is a backstop for the failures nobody has diagnosed yet, not for this one. scripts/visual-harness/probe-first-paint.mjs asserts the property against a real browser with the token endpoint held open.

Every page is its own chunk, and what loads before first paint has a budget (PERF-01, 2026-09-26). main.tsx registers each product page with lazyRoute() (lib/lazyRoute.ts) instead of importing it, so the entry carries React, the router and the shell, and a page arrives as its own chunk. The router preloads it on hover and touchstart (defaultPreload: 'intent') and awaits it before committing a navigation, so there is no flash; the likely next pages are warmed once the first has loaded; and the service worker precaches every page chunk with the rest of Tier 0, so offline is unchanged. The auth pages stay static: ResetPassword must read the recovery link's URL at module scope, before auth-js rewrites it. React's first commit waits for router.load(), so the inline boot card stays up until the whole first page can paint, and the first-paint watchdog covers a page chunk that never arrives as well as an entry that never does. The two always-mounted features load only for whoever can see them — Deck-E's host for an entitled account, the support prompt (and Stripe's payment UI) for a signed-in cloud visitor, at idle — and lib/billing.ts imports @stripe/stripe-js/pure, because the package's main entry fetches Stripe.js as a side effect of being imported. A page chunk that vanished in a deploy costs one reload, under a service worker or not. The retry guard is scoped to that chunk, so a parent page loading cannot clear a missing child's guard and start a reload loop. scripts/check-critical-path.mjs fails the build if the scripts index.html loads up front exceed 230 kB gzipped (about 200 kB now, against 364 kB when every page was in the entry).

Writes go through a lane, and end visibly (2026-09-26). Every collection, list and deck write is sent by lib/writeLane.ts: one request in flight per document (collection:<setId>, list:<id>, deck:<id>), so answers — which are whole-document snapshots — land in the order sent; a write still waiting its turn is replaced by a newer one for the same item, so a burst of taps sends the last intent rather than every step; and the control shows that intent until the item's last write settles, which makes rollback nothing more than the intent going away. That only works because every such write states an absolute target (PATCH a quantity, never …/increment). lib/writes.ts is the single place that reports the outcome: a final failure raises the Toast with what did not save, why when it is actionable, and Retry when repeating is harmless; a form that stays open reports inline through FormAlert; a destructive success offers Undo. A write that does not answer in 20 s is aborted so it cannot hold its document's later writes, and every lane is cancelled when the signed-in account changes. A write the server never answered (the deadline, a dropped connection) may still land, so for that item the lane fails the newer write queued behind it and sends nothing more for 75 s after the unanswered one left — past the API function's 60 s limit. Answers go into the cache through applyAnswer, which cancels any read of the same data still in flight and asks it again afterwards. Nothing is queued offline — the service worker keeps mutations NetworkOnly, and the collection counters stay disabled offline.

Search engines see each public page as its own page (2026-10-04). Every route is the same index.html, so this takes three pieces.

  • Prerendered documents. For crawlers that do not run JavaScript, the build writes landing.html and connect.html (vite-plugins/landing-prerender.ts). Each is that shell with the page's words in #root plus its own title, description and canonical. The root middleware.ts (Vercel Routing Middleware) rewrites / and /connect to them. A vercel.json rewrite cannot do this for /, because Vercel serves the real index.html before applying rewrites. React replaces the words on first commit like any other page.
  • Runtime metadata. For Google, which renders JavaScript, lib/seo.ts's usePageMeta gives every public page its own title, description and canonical. The canonical is built from the record on card and set pages, and never carries a query string. A real 404, search results, the sign-in pages and unknown URLs are noindex. A passing error only drops the canonical.
  • Sitemaps. For discovery, apps/api/src/sitemaps.ts serves /sitemap-{pages,sets,cards,pokedex}.xml from the live catalog (about 21,000 card URLs), listed by the static index public/sitemap.xml that robots.txt names. They are cloud-only and cached for a day at the CDN.

A new public page calls usePageMeta. If it is top-level, it also joins PAGES in sitemaps.ts.

14. Design system and the /design editor

The visual language is a token system in apps/web/src/theme.css: three brand hue scales (cyan / pink / amber, Tailwind-4 values) feed ~77 flat semantic roles (surfaces on the warm stone scale, text/borders re-derived in OKLCh to match, actions, status, energy types, variant accents, z-layers). Two type roles — Figtree (body/UI) and Fraunces (display, reserved for the app's proper nouns) — with a 14px floor and named exceptions. Shared primitives live in apps/web/src/components/ui/ (Button, Tabs, Progress, StatTile, SelectableCard, EmptyState, CounterBox, Field/FormAlert/StatusPanel, Toast, useDismiss), each with a co-located *.gallery.tsx that type-checks its catalog entry against the real prop surface. The premium visual pass (premium.css) is scoped entirely under [data-skin='premium'] — a reversible skin, toggleable live (?skin=classic), same for the top-bar treatment (?topbar=flat).

The /design surface renders the token panel, the gallery catalog, and the componentization ledger. It has two modes with one structural rule: write capability exists only in the dev server.

  • Dev: a Vite dev-server plugin (apps/web/vite-plugins/design-editor.ts) serves /__design/* endpoints — token reads, anchored single-declaration writes to theme.css (Lane A), and a JSON change-request queue drained by an agent with judgment (Lane B, design-requests/). The endpoints exist only while vite dev runs; they are absent from build output by construction, not by configuration.
  • Deployed app: the route requires design.view from the server's current database permissions, including on preview. The compatibility designEditor flag is derived from the same permission. The missing local design endpoints keep the deployed page read-only: tokens are parsed from bundled CSS, saves/composers stay hidden, and ephemeral previews remain.

Reusable administration DataTable — 2026-09-14

DataTable<T> and DataTableToolbar are exported from apps/web/src/components/ui.tsx. The caller owns rows, stable getRowId, queries and all filtering/sorting/paging; the table never transforms rows. Columns define id, header and cell(row), with optional sortable, align, className and headerClassName. Sorting is controlled by sort: {columnId, direction} and onSortChange; a header becomes a sort button only when both its column and the caller support sorting.

Pagination takes {offset, pageSize, total, onOffsetChange, onPageSizeChange?}. Supply the exact total for the applied query. The optional size callback enables 25/50/100 choices and a reset to offset zero; callers also reset offset with applied filters/sort. Settled out-of-range pages request the last valid offset. Loading/refreshing/error states withhold old rows and expanded details; placeholder totals cannot trigger recovery while pending. Pass loading/refreshing until rows and total match the applied query, plus error/onRetry and custom empty content as appropriate.

The toolbar composes labelled search, child filters, actions, submit and reset controls; callers choose submitted server filters or live local transforms. renderExpandedRow and getRowLabel provide explicit details buttons; expansion is scoped to the supplied rows array. One semantic table serves desktop and 390px. Its named region becomes keyboard-scrollable when it overflows; the scroll hint appears only then. Horizontal overflow stays inside the content column. Captions, column scope, aria-sort, result announcements and explicit links/actions preserve table semantics without mobile record cards.

Consumer Matching and order
Users Server username/email search or exact ID, status and exact assigned role; created-at/ID ascending, with no client-page sort. Role filtering needs roles.read without blocking users.read access.
Audit Exact server actor/action/target filters, newest-first order and paged totals; before/after JSON uses row details.
Credit orders Server status/exact user ID filters and paging; no invented global sorting.
Roles Complete-list name/key/description and protected/custom filters; name/member-count/permission-count sorting before local paging.
Credit packs Complete-list name and active/inactive filters; name/credits/sale-price sorting before local paging.
User credit ledger Existing server order and fixed 25-row pages; credit/debt/reason information remains.

Minimal fixed-page consumer, after the caller has resolved one matching page:

import { DataTable, type DataTableColumn } from '../../components/ui'

type RecordRow = { id: string; name: string }
const columns: DataTableColumn<RecordRow>[] = [
  { id: 'name', header: 'Name', cell: row => row.name },
]
export function RecordPage(props: {
  rows: readonly RecordRow[]; total: number; offset: number
  onOffsetChange: (offset: number) => void
}) {
  return <DataTable label="Records" rows={props.rows} columns={columns}
    getRowId={row => row.id}
    pagination={{ offset: props.offset, pageSize: 25, total: props.total,
      onOffsetChange: props.onOffsetChange }} />
}

The co-located DataTable.gallery.tsx is discovered by the existing CatalogSection glob: find DataTable in the /design component catalog. Its 63 fictional records demonstrate complete-list search/status/sort, paging, disclosure and loading/empty/error-and-retry states. Existing permissions, private query scoping, action sheets and financial rules remain with callers. This adds no virtualization, column persistence, exports or backend-query optimization. On 2026-09-14, the complete isolated browser suite passed 57 groups, including these table consumers and the design gallery, in cloud/self-host builds. Desktop 1280px and phone 390px screenshots were directly reviewed; actual keyboard scrolling and compact action-row bounds were also checked. The table branch has a preview. Fixture acceptance does not establish a production release or live authentication, database or payment behavior.

15. Deck-E — the 3D character runtime

Status: implemented runtime; access is permission-based. The /dev/decke review route requires diagnostics.view. Browser guards and server-side gates use current database authority; no owner UUID enters the bundle, and preview does not bypass permission checks. The shared Deck-E host is suspended on /dev/quad-labeler, including its renderer warmup and wallet query. The internal camera/photo workbench keeps the app shell and Queue controls, but does not compete with another WebGL canvas. The host resumes its normal route behavior after navigation away.

Shipping it means the chunk is emitted (~1.17 MB of three.js and the runtime, measured 2026-08-22 and approximate on purpose — the precise figure drifts with every dependency bump, and scripts/check-precache.mjs rather than any number written here is what actually enforces that none of it reaches the precache) and the 5.6 MB of assets sit in public/models/. Neither is downloaded by anyone who does not open the route, because the import is lazy — provided both stay out of the PWA precache, which is eager and would otherwise pull 6.5 MB into every visitor's cache on first load for a route exactly one account can open. vite.config.ts excludes models/** and assets/Decke-*.js; measured, the precache is unchanged at 25 entries / 1964 KiB with the route shipping.

The precache is not the only door, and that sentence used to end here. It was wrong for months. Issue #75 (2026-08-24) found the character chunk being fetched by every visitor anyway — not by the service worker but by index.html, as <link rel="modulepreload">, ahead of first paint. An advancedChunks group absorbs the DEPENDENCIES of the modules it matches, so character/decke/cardSource.ts importing lib/api.ts pulled lib/api.ts (and supabase.ts, landingRoute.ts, returningVisitor.ts) into the character chunk; the entry imports those from ~50 places, so the entry gained a static edge to three.js and Vite preloaded it. Cold first content measured 6.3 s on a throttled connection against 0.3 s warm.

Two things hold the line now. vite.config.ts claims src/lib/** and src/character/*.ts into a higher-priority app-lib group so the character group cannot own them — which is also why viewport.ts, beacon.ts and cardSource.ts live directly under character/ rather than in decke/: they import no three.js, the app shell imports them statically, and inside decke/ each such import was another rope tying the entry to the engine. And check-precache.mjs gate THREE fails the build if anything index.html references directly contains three.js. When adding a "this must not ship to everyone" rule, enumerate the doors — a name-based or single-door guard will pass while the payload uses the other one.

Deck-E is a stylized robot deck box who IS the AI assistant's body: the LLM drives his animation from the conversation, and when he presents part of the UI he parks beside that element facing inward. §15b covers that layer — the runtime below is only the body. He is authored in Blender in a separate working directory (~/Documents/DeckPal Character, which carries its own wiki) and re-implemented here.

Shape of it

apps/web/src/character/decke/
  DeckE.ts        the controller — owns the scene, the loop, the public API
  stage.ts        renderer, camera, colour management, the six-light rig
  rig.ts          binds glTF nodes; applies a 47-channel pose to them
  field.ts        the analytic deformation field (ported from the wiki's Python)
  riders.ts       everything that is not a shell, kept attached to the shells
  playbook.ts     the 27 authored states, compiled to per-channel curves
  sustain.ts      the loop window per state, plus synthesized idle/sleep/outro clips
  curve.ts        Blender-compatible bezier evaluation, and cyclic seams
  procedural.ts   idle float, blink, gaze — seeded, deterministic
  look.ts         where the pupils point (the camera constraint that could not export)
  framing.ts      how he is SEEN wherever he stands — yaw, lean, vertical angle
  beacon.ts       where the off-screen chip goes, and how far to scroll for him
  flight.ts       the travel solver
  dom.ts          DOM element -> a place to stand, and the keep-out region
  commands.ts     the JSON command surface an LLM drives
  cards.ts        the orbit, the hands, the presented card and the stash flight
  eyeSocket.ts    Eye_Rig's VERTEX_3 parenting to the morphed lid
  materials.ts    fixups for what the glTF exporter flattened
  eyes/           the analytic eye shader
apps/web/src/components/ui/elementHighlight.ts   the chasing ring he presents WITH,
                          which is a design-system primitive and not his
apps/web/src/components/ui/DeckeBeacon.tsx       the off-screen chip's DOM half
apps/web/scripts/decke/   generators for the playbook, cards and parity fixtures,
                          plus shrink.mjs (the asset compression step)
apps/web/public/models/decke/   .glb, playbook.json, markers.json, HDRI, atlas

The load-bearing decisions

  • Nothing is fetched until somebody asks. In the app he is loaded by DeckeButton's onWarm — pointer-enter, touch-start, focus — and by onOpen, and by nothing else. There is no idle or timer warm; an entitled visitor who never touches the launcher downloads no character asset on any page. The launcher's waking state (a travelling ring, not a pulse and not a progress bar — there is no total until the first response header lands) is load-bearing UI while that download runs, and it stays mounted until he has genuinely arrived, rising above the chat scrim so it is not covered by the surface it is standing in for. A load that fails says so and offers the way back. A phone has no hover, so mobile trades "already there" for "tap, then wait"; a question typed before he lands is held, shown on the transcript within a frame of the press, and asked when he arrives.
  • Loading finishes at entry scale 0, and the entrance is a rig-root screen space scale on DeckE_Root with a pivot correction, not a camera move. setCharacterHeight dollies the CAMERA with the height in the denominator, so "grow from nothing" would ask the camera to travel to infinity and is not a number at zero; minimum scale is 1e-3 for the same reason. Nothing below the root has to know — riders.ts and eyeSocket.ts premultiply their parent's inverse world matrix, the eye shader works in object space and look.ts solves a ratio, so the factor cancels. What does have to know is anything measuring him in the world: screenRect and the beacon are both told. Finishing at zero is what keeps warming-on-hover from putting the 3D body and the launcher chip on screen together, which is the invariant the whole well design exists to protect.
  • There is a region he may not stand in. dom.ts holds a keep-out region whose bands the HOST measures from real CSS-sized elements (--app-header-h plus env(safe-area-inset-top) at the top; the install-pill clearance at the bottom, zero while the chat is open because his phone park box deliberately overlaps the composer) and the engine applies. It is a clamp, not a veto: asked to present a nav item in the header he is pushed down until his head rests on the band, still in the item's column and still turned back across it. It applies to PLACEMENTS — a flight, a re-park after a resize, a band change — and never to the per-frame scroll track, because he has to be able to leave the viewport vertically for the off-screen beacon to exist at all. A band of zero is no band, so every non-host caller (/dev/decke included) keeps the previous behaviour exactly.
  • The host owns the media query; the engine owns the behaviour. Nothing in character/decke/ calls matchMedia. DeckeHost reads prefers-reduced-motion, watches it live, and passes it to DeckE as a flag; entry, flight and escort legs each have a real instant-arrive mode behind it, so the same host code is the reduce path with no branch.
  • No AnimationMixer. Motion is a 47-channel normalised pose evaluated per frame, not TRS tracks. See the 2026-08-18 entry in DECISIONS.md for why — briefly, one channel fans out to several rig targets through non-linear mappings, and the channel semantics are what the LLM driver needs.
  • A state is ongoing, not a one-shot. Every clip runs intro → sustain → outro, where the sustain LOOPS a window of the clip until something else is asked for; setState also takes durationMs and then so the driver can spend a state as a beat instead of a mood. Without this a clip holds its last beat forever, and since nearly every last beat is the rest pose, "be happy" decays to "be nothing". idle is synthesized, because the playbook has no such state and boot was standing in for it — badly, since boot's modulation freezes the float and the blink. 2026-08-20 in DECISIONS.md.
  • Constraints do not export, and one of them was load-bearing. The pupils track the camera in the .blend; glTF emitted the target as a childless root node, so the port wrote the gaze into the void and rendered the bind pose, which is a baked sample of the very constraint that was missing. look.ts rebuilds the aim. Worth remembering as a class: a frozen constraint is invisible to any parity check taken at the frame it was frozen on.
  • A sustain is its own cyclic clip. The loop window is compiled once at load into a clip whose tail beat is a COPY of its head and whose seam carries one shared tangent, so the two ends agree in value AND in velocity by construction rather than by inspection. Choosing plausible beat times is not enough: beats are sparse and an omitted channel reads as REST, which is how curious came to drop 0.04 units and put them back once a second. A stepped channel still steps across the wrap, because confused, frustrated and the alerts are authored in a robot register and that tick is the point. 2026-08-20 in DECISIONS.md.
  • Where he stands and how he is SEEN are different problems. The Blender camera is fixed and aimed at the origin, so parking him anywhere else changes the 3/4 angle the whole facing system is defined against and keystones him into a lean. framing.ts gives every position its own view frame and rotates him into it, then deliberately gives back the ELEVATION so his vertical angle still follows his height on the page. At the staging origin the solve is exactly the identity, which is what keeps parity meaningful, and a test pins that. The lighting rig and the environment take the same transform, because a character who presents the same view of himself everywhere should be lit the same way too.
  • Where he is parked is a STATION, not a coordinate. An element station is a promise to stay beside a DOM rect, so it is re-solved when the page scrolls or resizes — which is also how he starts at home rather than dead centre. Scroll him out of the viewport and the beacon appears: a chip at the edge holding a LIVE second render of the scene, drawn as a scissored second pass on the same canvas rather than in a second WebGL context.
  • Vanilla three.js, not react-three-fiber. The character is driven imperatively; the controller never imports React.
  • The deformation field is evaluated live, so continuous channel values produce a correct rig rather than an interpolation between authored poses.
  • The eyes are a custom shader, not a texture. Every feature — pupil, shine, lids, alert glyph — is analytic maths evaluated in the fragment shader, so it stays sharp at any zoom.

Verification

Parity against Blender is a build-time concern, not a vibe. Three fixtures are generated by EXECUTING the character wiki's own Python rather than re-transcribing it, so the port is compared against ground truth:

Harness Checks
scripts/decke/gen-field-fixture.py the deformation field, to 1e-9 on position and 1e-6 on the rider matrix
scripts/decke/gen-proc-fixture.py the PRNG, idle float and blink curve, to 1e-12
scripts/decke/gen-playbook.py --check the playbook still matches its sources
?parity=1 on the route reproduces Blender's exact camera and backdrop for image diffing

| scripts/decke/gen-cards.py --check | the card waypoints still match the baked F-curves |

markers.json maps every Blender timeline frame to its state, which is what makes frame-by-frame comparison possible at all. Silhouette IoU across eight reference poses runs 0.90–0.99; apps/web/src/character/decke/PARITY.md carries the per-frame numbers and, importantly, the list of things that are knowingly not matched and why.

The largest of those: the idle float and the blink are seeded procedural layers in the port and baked curves in Blender. They cannot be frame-matched and should not be — he has to idle and blink indefinitely rather than replay 5211 frames — so they account for most of the remaining centroid error, and make two of the eight frames swing by up to 0.05 IoU between identical runs.

The asset

The shipped decke.glb is 2.92 MB, down from a 7.48 MB raw export, via scripts/decke/shrink.mjs. Two hard rules live in that script and are worth repeating here: never Draco (KHR_draco_mesh_compression structurally cannot carry morph targets, and every body deformation on this character is one), and never quantize (KHR_mesh_quantization parks a de-quantisation transform on each mesh's node, which the rider system then overwrites — it inflates Hinge_Pin_R into a cylinder wider than the character).

Run the unit tests with:

pnpm --filter deckpal-web test:decke

Three of those are PROPERTY tests over a whole input range rather than examples, and two of the three found real defects while they were being written: the stash fan cannot produce two overlapping cards at any batch size from 1 to 12 (the first, polar, layout failed at five), no card stands in front of his face, and no gaze flit ever lands closer than the gate to the one before it (the schedules were rebuilding from scratch every ten minutes, which made the gate a property of one generation rather than of the run).

15b. Deck-E — the assistant layer

Status: routed assistant harness, 2026-10-10. The layer described in §15b–§15g uses Claude Haiku 5.5 for triage and Quick work, Claude Sonnet 5.5 for Standard work, shared agent tools, pathway guidance, write approvals, grounding and narration controls, and the chat surface. Claude Opus 5.5 runs only as reader-consented Deep Think (§15d). Verification of it has been done against previews and against the live backend as the QA account, never the owner's, per contract B12. It needs DECKE_VERCEL_AI_GATEWAY_KEY in the Vercel project; it fails closed without one and reports its own readiness on /api/health. He now holds all 26 of packages/agent-tools' tools (§15c) — the write half held behind an approval round trip (§15e) rather than filtered out — plus research and deck checking in the normal streamed loop, against the six cosmetic tools of the original ship.

Where §15 is the body, this is everything that decides what the body does. Every request first gets a bounded Haiku 5.5 triage call; Haiku fills a typed rubric (pathway, signals, missing inputs and deep intent), and a deterministic heuristic fills it if triage times out, errors, refuses or returns an invalid call. Code in tiers.ts, not the classifier's confidence, then chooses the tier from pathway floors and carried guard state, once per reader turn: the first HTTP leg streams its decision as a transient data-decke-route part and the browser echoes it as tierRoute on every continuation leg (routeEcho.ts), so an approval or a browser tool never re-routes a turn mid-way. This follows the measured design in roadmap/plans/decke-harness-v2/PLAN.md §2: a small model is useful for classification/routing/extraction, while model self-grading is not a dependable escalation gate.

The chosen model receives the stable core, the selected pathway instructions and the uncached request context, then runs the normal tool loop. Turn guards bound the result. A fixable guard gets a corrective leg on Standard; a silent Quick content-filter refusal gets one Standard attempt in the same reader turn. The remaining boundaries carry the rest of the design.

The command channel is invisible. The model never emits animation syntax into its prose. It calls an express tool, whose execute validates the commands and writes them to a transient data part — writer.write({ type: 'data-decke', …, transient: true }). Transient parts reach the browser and never enter message history, so there is no token for a half-parsed command to leak through and nothing for the next turn to imitate. The same mechanism carries showScreen.

Everything the model can point at is allowlisted. uiTools.resolveTarget resolves a selector only if it lands inside [data-decke-landmark], and navigation only to ROUTE_ALLOWLIST. The screen palette is the same rule applied to markup: screens.ts defines a closed set of block kinds whose props are enums, numbers and plain strings, and DeckeScreen.tsx is a switch that renders null for anything it does not recognise. There is no field anywhere in that schema that carries HTML, a class name, a style, a URL or a selector — so it is not a sanitised injection surface, it is not an injection surface.

Who may talk to him, and how much, is decided on the server. Deck-E requires current decke.use permission and an active account. When database credit policy is enabled, each priced operation reserves an atomic flat charge with its policy snapshot before invocation; accounting errors fail closed. When charging is disabled, ordinary accounts use the daily chat/deep counters; explicit unlimited overrides retain access/hold/budget checks without debit or daily allowance. Lifecycle policy derives product permission; retired entitlement allowlists do not grant it. The credits-enable flag initializes policy once. See ADMINISTRATION.md and SECURITY.md for limits, refunds and suspension.

The import errand is separate from chat. When an import check finds unmatched lines, POST /api/decks/import/fix builds catalogue candidates and asks the chat-tier model to select their keys. A server membership check and a second pass through the normal resolver guard every replacement. The UI reserves a bay in the import dialog for Deck-E, shows each old and proposed line with Undo, and sends the confirmed text through the no-write check again before any deck is created. The import review is a projection of current text, format, stable line IDs, accepted correction provenance, and the exact revision's read-only check. Edits, Undo, and format changes invalidate that check and refresh it after 300 ms of idle time; no summary is incrementally relabelled as current. Superseded requests are aborted, and both a request sequence and the source revision guard success and error responses. Import remains disabled with a checking state during the debounce and request. An explicit Import always runs its final check immediately. Only the explicit “Import without them” action authorizes skipping the displayed unresolved line IDs, and any source change revokes that permission. Both the button and the final check use the same write gate. Accepted corrections remain undoable while their physical lines survive. Their card provenance lasts for the dialog so blank lines, whitespace, or duplicate deletion cannot erase a format guard; ordinary raw-deck imports retain their existing card-legality behavior. GLC imports infer their type only when every resolved Pokémon shares exactly one type; no implicit Grass default is used. An ambiguous type leaves accepted Pokémon corrections pending with an explanation in their rows. Definitive format issues still apply. The write endpoint requires a known or explicitly provided GLC type before creating a deck. The errand store only moves the character; it does not grant permission or save cards. The route shares Deck-E's entitlement, Gateway key, daily meter, and usage ledger. When paid credits are enabled, it converts the provider-reported cost using the request's credit policy and accumulates the fraction. Whole credits are debited through the existing wallet ledger as the fractions add up. Import repair costs remain in usage history but are excluded from the planning price estimates. Admission commits on the request's database connection before the model call, which releases that connection. Settlement later checks out one connection from the same shared pool, so a disconnected browser cannot erase the daily charge or consume a second pool.

One controller, one writer. runtime.ts holds a single WebGL context with deferred disposal so React StrictMode's double-mount does not build two. Exactly one place computes his height, because two writers fought over it and the ResizeObserver won.

apps/web/src/character/host/
  DeckeHost.tsx    mounted beside the shell, so he survives route changes;
                   owns the load-on-intent lifecycle, the reduced-motion query
                   and the measured keep-out bands
  DeckeChat.tsx    the chat, which IS the content pane; the ChatMessage model
                   and the docked AskCard answer boundary
  useDeckeChat.ts  hand-rolled SSE reader, client-tool execution, one round
  uiTools.ts       flyTo / highlight / goTo / scrollToMe / click + the allowlists
  journey.ts       the client-side journey sequencer (§15g)
  DeckeScreen.tsx  the renderer half of the screen palette
  DeckeBubble.tsx  speech bubble placement — never covers what he highlights
  chat/            the transcript's presentation kit (§15g): ChatMarkdown(Body),
                   ToolRow, ThinkingRow, CardRow, ApprovalCard, and a pure
                   state module per row so each is testable without a DOM
  ripSession.ts    pack-rip dedup state machine (pure)
  ripCommit.ts     cardId -> variantId, one batched write
  ripPresence.ts   the rarity heuristic ONLY — the rip reaction was removed
                   rather than disabled, with the reasoning in the file
  runtime.ts       lazy engine load + single-controller guard
  entitlement.ts   the single gate
  approval.ts      the write-approval round trip, as two pure functions (§15e)
apps/web/src/lib/markdownSafety.ts   what model-written markdown may become,
                   shared by the chat renderer and the deck strategy view (§15g)

apps/api/src/decke/
  ctx.ts               builds a Ctx from the caller's JWT; lazy Ctx.db (§15c)
  rls.ts               the per-tool-call RLS session + its watchdog (§15c)
  entitlement.ts        current database decke.use permission
  meter.ts              the chat and per-operation credit meter, check-and-charge in one statement
  triage.ts             Haiku 5.5 typed rubric; deterministic fallback on every failure
  tiers.ts              code-owned pathway floors, escalation and effort
  pathways/             per-job instructions and their names.ts routing metadata
  models.ts             TIERS: Quick Haiku 5.5, Standard Sonnet 5.5,
                        Deep Opus 5.5 (Deep Think, consent-gated)
  deepThink.ts          Deep Think eligibility, offer/grant tokens, the two Opus routes and the balance floor (§15d)
  consult.ts            Quick asks Sonnet 5.5 one question with a brief it writes (§15d)
  adapters/aisdk.ts     ToolDefinition -> the AI SDK's tool(), plus the approval policy (§15c, §15e)
  noOp.ts               "would this write change anything?" -- no dialog if not (§15e)
  focus.ts              every real tool on every step; only a spent meter cap can hide one (§15f)
  stopRule.ts           `spokeAndSettled`: speech after work, then a cosmetic settle (§15f)
  pasteBackstop.ts      bounded recovery to the signed log card for an unhandled pasted battle (§15f)
  grounding.ts          the card ids a tool actually returned this turn (§15f)
  narration.ts          tool syntax that reached the reader as prose, removed (§15f)
  jev.ts                typed judgments from Jev, and null (= today's behaviour) on any failure
  reflex.ts             the pre-turn read: force the consent card, steer the walk, hear a spoken no
  audit.ts              the after-turn check: a claimed change no tool made gets one corrective card
  eval/                 the labelled, synthetic judgment eval set and its scorer
  prompt.ts, tools.ts, screens.ts, gate.ts   system prompt, express/showScreen, screen palette, owner gate
api/chat.mjs          the standalone serverless brain

Judgments ride under the words. DECKE_JEV=on adds one typed evaluation of the reader's latest message per request (reflex.ts, ~0.3 s, ~$0.00004): on the leg carrying that message it can pin step one to log_cards — which only ever raises the signed consent card — hide escort, and add a spoken refusal to the declined ledger; after the reply, audit.ts runs one corrective step (pinned to the consent card's tool) when he claimed a collection, list, deck or battle-log change that no tool made. Claims that a list, deck or battle log was deleted get an admission instead: the available corrective edit tools cannot delete them. Every answer acts only above a threshold chosen on eval/judgments.json; a timeout, an error or a low answer is today's harness exactly. Jev never approves a write and is not a security control.

On a corrective list, deck or battle-log step, the tool schema supplies dry_run: false before the SDK signs the call. The normal tool schema still defaults to a preview; the correction's signed apply intent survives the approval replay on the next request, and the adapter still holds the write until the reader approves it.

api/chat.mjs is deliberately standalone rather than a route on the Express app: production needs streaming under the RLS-authenticated request, and the two did not compose. It is a web-standard handler using createGateway({ apiKey }) — passing the key as a header is silently ignored and bills the wrong account.

spokeAndSettled ends the turn when he has spoken after his last non-cosmetic tool call and his last step called only express or showScreen. Stopping on the tool call alone silenced him, because he does not reliably speak before he moves; leaving the settle rule out made him deliver two near-identical closing lines. Both were measured; speech before a later lookup is instead an interim progress line and does not end the turn. The 2026-10-10 decision records the rule and its retained duplicate-answer and triple-screen guards.

15c. Deck-E's data access

He carries no credential of his own. api/chat.mjs verifies the caller's Supabase JWT the same way the Express app does, and ctx.ts builds a Ctx from it — apiBaseFor() derives the API base from the request's own Host header rather than a hardcoded one, so a preview deployment talks to itself rather than to production. Ctx.api forwards that same JWT to deckpal-api, so every write a deep tool makes runs under RLS as the person in the conversation, not as a service role — there is no service-role credential anywhere on this path, and the write logic it calls into is the same apps/api/src/routes/* code the REST API and the web UI use (§10).

Ctx.db is lazy. It looks like an open connection and is not — it checks one out on the tool's first query (openRlsSession() in rls.ts, the same BEGIN; SELECT set_config('request.jwt.claims', …); SET LOCAL role = 'authenticated' shape §5's withUserContext and the MCP server's own RLS session use) and releases it the moment the tool call returns. Roughly half the tools never touch Postgres at all — writes go through the REST API — so an eager session would spend a pooled connection on tool calls that run no query. An ordinary conversational turn with no data tool touches the database not at all.

The watchdog the other two RLS doors don't need. Aborts are routine on this path — the client aborts the previous stream on every new send, and there is a stop button — and when the socket dies mid tool-call, Vercel freezes the instance with a checked-out client still inside an open transaction. openRlsSession() starts a timer (DECKE_PGRLS_MAX_HOLD_MS, default 10 s — deliberately far below the API's own 30 s PGRLS_MAX_HOLD_MS, because the unit here is one tool call, not a whole request) and, on timeout or abort, destroys the connection (client.release(true)) rather than returning it to the pool: a connection mid-statement inside another user's RLS claims must never be handed to the next request. A destroyed connection costs one reconnect; a shared one costs a cross-user data leak.

The connection story. api/chat.mjs is a separate Vercel function from the Express catch-all, with its own small pool (PGPOOL_MAX_CHAT, default 2, lazily created on first use, contract B2's request role). It shares no memory with apps/api/src/index.ts, so /api/health's live pool census (total/idle/waiting, B2) structurally cannot see it — /health instead reports the chat pool's configured size (deckeLimits.chatPoolMaxConfigured) rather than pretending to measure something it cannot reach.

15d. Routing, planning and live research in the chat loop

The pathway table in pathways/names.ts is the quality/cost dial. deck_build, deck_iterate and battle_review have a Standard/Sonnet floor; battle_log, collection_plan, lists, price_value, card_rules, research, navigate, small_talk and general start Quick/Haiku. Quick navigation and small talk use low effort; the other Quick pathways use medium. Standard uses medium and the Deep tier uses high. Signals such as dissatisfaction or correction, carried guard failures, repeated tool errors and Deep interest raise Quick to Standard; none can enter Deep without reader consent.

Deep Think is that consent flow. It exists only on a deployment that signs approvals (DECKE_APPROVAL_SECRET; unsigned, the tool is not in the set and nothing routes to Opus) and only for metered credits: a paid wallet, priced, or an unlimited account, unpriced. The daily allowance, credits off and flat v1 pricing never see it. Deck-E offers it when the pathway rubric says the job merits depth (wantsDeep: offer) or the reader asks for deeper analysis (requested); either way the turn stays on Standard until he agrees. The offer is the deep_think tool, which raises the ordinary signed approval card. Beside that card the server streams a data-decke-deep-offer part keyed to the call: an offer token, and for a paid wallet its estimate ("About low-high credits", rendered by deepThinkCard.ts above the model-written why/plan; a model never writes the number). "Use Deep Think" stays disabled until the wallet balance read after the card went up has landed, and becomes Top up when that balance is below the estimate's high end.

A request reaches Opus by exactly two routes, both in deepThink.ts and both read before admission because they size the hold:

  • The approval leg (deepApprovalLeg): the final message ENDS with an approval-responded, approved deep_think part — the one shape ai@7's collectToolApprovals hands to validateApprovedToolApprovals, whose HMAC check (approval id, call id, tool name, input) throws before any model call. An approved part anywhere else, or an output-available part carrying an approval, is never validated by the SDK and is never authority here. The SDK's HMAC covers the call, not the turn, so the part must also carry the offer token: an HMAC under a key derived from the same secret over user, conversation, exchange (the browser's per-turn id), a digest of the reader's latest message, call id and approval id. An old turn's genuine approval replayed into a new turn fails it.
  • A later leg of that turn (deepGrantThisTurn): deep_think's execute — which runs only after the SDK accepted the signature — mints a grant (HMAC over user, conversation, exchange and latest-message digest, 15 minutes) into its output, and emits its own start/ok row ("Used Deep Think"). The browser turns the answered approval into that finished result on the next leg (settleApprovedCalls), which both pairs the call with a result and carries the grant; a grant from another turn, user or conversation, a tampered one or an expired one does not verify.

Both routes outrank the browser's route echo (turnDecision): every Deep leg is a continuation, and the echo can never carry Deep itself. Only those routes add migration 083's hold multiplier (bounded 1-10; Deep asks for 8x the 25-credit hold); settlement is still the actual Gateway cost and the unused hold returns. A paid reader whose spendable credits are below the HIGH end of the estimate answers on Standard with a one-line note (settleDeepDecision): the cap is only checked when a model operation starts, so one 32k-token Opus step could otherwise overshoot a small hold into the overage buffer. Once a turn has any deep_think answer, and throughout a Deep run, the offer line and the tool leave the model's view for that request (constant per request), and a Deep run has no ask_user (its answer would be a new turn without the grant; the tool result tells Opus to state assumptions instead). "Keep it quick" sends its own reason ([[KEEP_QUICK]] …) and the turn answers on Standard.

Effort is a top-level Anthropic provider option beside thinking: { type: 'adaptive' }. Putting effort inside thinking is not an equivalent spelling: the Gateway silently strips that nested shape. Triage is the exception: it disables optional thinking and uses low effort for its one forced, bounded rubric call.

The system prompt has three ownership regions. The byte-stable core is cached; the selected pathway block (which can contain one or two templates) is separately cached; route, date and landmarks live in a final volatile block with no cache marker. On every model step cacheConversation removes the old history marker and moves one breakpoint to the newest message, making all earlier messages and tool results the reusable prefix. A live Gateway probe on 2026-10-10 observed cache reads for both Haiku 5.5 and Sonnet 5.5; that verifies this route through the Gateway, not a claim that two models share a cache (they do not).

A Quick turn can buy judgement one question at a time (2026-10-10). On the Quick tier only, Deck-E holds consult (consult.ts): one question plus a brief he writes — for a game review, the battle_digest text and the reader's own words — sent to Claude Sonnet 5.5 at medium effort with no tools and no conversation. It runs at most twice per request, inside observeUsageModel so it is metered on the reader's request like every other model call, and under the turn's abort. Its answer returns to Deck-E, not the reader, and is never passed to grounding.observe: every card id in it came from his own brief. The tool is removed on Standard and Deep by toolsForTier, decided once per request from the routed tier so the tool list (and Anthropic's cached tools prefix) never changes between steps; a consult that then says nothing is still an empty answer, since SERVER_SET in api/chat.mjs excludes it.

The selected conversational tier plans, analyses and drafts guides in Deck-E's normal streamed tool loop rather than delegating to a nested sub-agent. plan_deck, analyze_collection and write_strategy_guide are absent. web_research is a read-only Perplexity operation with no approval card; its short purpose becomes the activity label, source hosts enter the model context, and HTTPS source URLs are sent only to the browser. check_deck validates a proposed list before showDeck renders its saveable deck widget. Credits remain flat per operation in this release, including research at the analysis price.

A revision saves as a version of the deck it revises (2026-10-10). showDeck takes an optional deck_id (and a version_note). The server resolves the id among the reader's own decks, as the reader (ownedDeck in apps/api/src/decke/deckCheck.ts), and only then adds base: { id, name } to the deck block — a server-only field that showScreen's schema cannot carry and sanitizeScreen strips. With a base, the widget leads with "Save as new version" and an editable note, through POST /decks/save with newVersion: true (the route checks ownership again, keeps printings and pins, and always lands a changed list as the next version); "Save as a separate deck" remains the secondary action. Without one — a brand-new deck, or an id that is not the reader's — the widget saves a new deck through /decks/import, exactly as before. Either save is the reader's own click, so no approval card; the next turn is told what was saved (savedDeckRecord).

Not shipped: foil/variant auto-detection. research/FOIL-DETECTION.md has the measurement — the signal is real but not lighting-invariant, so the printing is a one-tap reader choice in the rip list instead.

15e. Writes ask permission, and the asking is the SDK's

Every write tool declares needsApproval: the turn pauses, the reader answers, and nothing is written until they do. That is a mechanism rather than an instruction, verified against the pinned ai@7.0.66 rather than read from a changelog — with needsApproval: true a tool's execute ran exactly 0 times while the wire carried {"type":"tool-approval-request","approvalId":"…","toolCallId":"call_w"}. This codebase has already recorded "a prompt is not an enforcement mechanism" twice, once about click and once about asking a model not to repeat itself; "wait for confirmation before writing" would have been the third.

What needs approval is derived from annotations and schema, never from the verb in the name: anything destructiveHint always, any real write always, and a preview never — being made to authorise something before being told what it would do is the opposite of the point. When a call is classified as a preview the server writes dry_run: true into the arguments explicitly rather than trusting the tool's default, so classification and coercion agree by construction, and only an explicit boolean false counts as permission to write, because 'false', 0, null, '' and a missing field are the values a model actually produces when it stringifies a boolean. The prompt the reader clicks through shows the dry run's OWN output rather than his description of it. A denial goes back as an answer, so he can say "alright, left it alone" instead of stopping mid-turn with no explanation, and an abort resolves the question as a denial — otherwise pressing stop with an approval on screen parks the turn's promise for ever.

log_cards has a narrower conversation-only contract. The model sees an APPLY tool with no dry_run property; the adapter owns dry_run:false, runs and caches a forced preview first, and lets needsApproval return true only for a successful, non-empty, fully actionable plan. Failed, invalid, unresolvable and skipped-only plans return their evidence without a dialog and cannot fall through to the write handler. preview_card_changes is the matching read-only alias: it uses the same shared handler with dry_run:true forced even if an unvalidated caller injects false. This opt-in exists only in api/chat.mjs; the shared log_cards schema, MCP behavior and buildDataTools default remain preview-first. Both tools are visible on the opening step.

The preflight cache is per buildDataTools request and keyed by both toolCallId and canonical exposed arguments. That detail follows the installed SDK rather than an assumed callback order: the streaming path enqueues the tool call before it finishes awaiting onInputAvailable, so needsApproval joins the same in-flight promise before an approval can be issued. The HMAC still binds the SDK's actual exposed input (which contains no dry_run); normalization happens inside the adapter after signing and never rewrites replayed wire input. The approved log_cards call gets a server-derived idempotency key from its SDK tool-call ID and signed exposed input, scoped by the collection endpoint to the authenticated user. Unsigned conversation metadata is excluded. The key survives a replay across the shared tool's 15-minute key boundary; a separate call gets a different key. The adapter adds it after approval, leaving signed input intact.

Every other approved write is keyed the same way (2026-10-10). A second approval-resume POST that differs in any unsigned field gets past the request key, and the SDK runs the held tool again. So execute derives approvedWriteKey (user, tool, SDK call ID, signed input) for any call held here, log_cards aside, and hands it to the tool's ctx. keyedApi in decke/ctx.ts then sends one Idempotency-Key per write request. The key is decke-call: + sha256 of that key plus the method, the path and how many times this execution has sent that method and path. Keying on the path rather than on global order means a replay whose reads differ, and which skips a write, cannot shift the others onto the wrong stored results. Previews (dryRun: true) and reads are never keyed. On the API side, writeOnce (apps/api/src/writeOnce.ts) opens the route's mutation_batch with that key as the transaction's first write, records the route's events into it, and stores the transaction's return value as its response. A replay returns that, and the tool's text gains a REPLAYED line so the model does not report a second write. A request without the header takes the unchanged code path. SECURITY.md lists the routes.

Two calls are answered without a dialog, and both are refusals to interrupt somebody for nothing. A write whose (tool, arguments) the reader has already declined in this conversation is refused with a sentence rather than asked a second time (decke/declined.ts; a pasted battle log is keyed by the game it carried, so declining one paste never refuses the next). And a write that would change nothing is not a write: decke/noOp.ts answers "would this change anything?" for the tools that can answer it cheaply, and deck_strategy sending back the guide already stored is neither asked about nor run. Measured against the live model, n=44, asked for insights about a deck: eight proposed guide writes, every one of them byte-identical to the stored guide.

Both consult the same predicate from needsApproval AND from execute, because needsApproval: false means "raise no dialog" and never "run it" — the two halves disagreeing is an unapproved write, which is strictly worse than the nuisance either fixes. Every path that cannot answer returns "it changes something", so a thrown fetch or an unresolvable deck costs a dialog and can never cost a silent write.

The answer travels back as the whole tool call, replayed with the verdict attached, and that construction has now produced two shipped defects. apps/web/src/character/host/approval.ts holds the two ends of it as two pure functions — pendingApprovalFromChunk, which captures what the tool-approval-request chunk carries, and approvalReplayPart, which rebuilds the call. It is a separate module rather than two closures in useDeckeChat.ts for a load-bearing reason and not a tidiness one: that hook imports lib/supabase, which reads import.meta.env at module scope, so under node --test there is no Vite and merely importing the hook throws before a single test runs. The logic was therefore unreachable by any test, which is how both of these shipped:

  1. A bare {type:'tool-approval-response', …} part. isToolUIPart in ai@7.0.66 is type.startsWith('tool-'), so convertToModelMessages read consent as a call to a tool NAMED "approval-response", with no toolCallId at all, and the next leg died in standardizePrompt.
  2. A dropped signature. experimental_toolApprovalSecret makes the SDK sign each approval and verify it coming back; DECKE_APPROVAL_SECRET is set in Production and Preview, so validateApprovedToolApprovals threw InvalidToolApprovalSignatureError: missing signature and every approved write failed. Turning the security control ON is what broke it, which is why no test that ran without the secret could see it — and none did.

Both had the same reader-facing shape: preview, "Go ahead", an apology, and nothing written — consent given, nothing happened, arriving through the control that exists to prevent exactly that. __tests__/approval.test.ts drives both functions through the REAL convertToModelMessages from the pinned package, and includes one test that feeds the bug-1 shape in and asserts it is still broken, so an SDK upgrade that fixes it fails the test rather than leaving a stale explanation in the codebase. Both bugs were re-introduced afterwards to prove the tests are load-bearing: 3 red and 2 red respectively, the real-SDK test among them each time.

The reader is asked by the CALL, and the prompt must not ask as well. prompt.ts opens the write protocol by saying that calling the tool IS how the reader gets asked, says explicitly that nothing has changed while a call is held, and forbids ending a turn on "Confirm?". That is not politeness: while the prompt told him to preview and wait, he previewed and waited — get_card, "Confirm and I'll log it?", zero log_cards calls, zero approval requests, nothing written, measured 0/15 on the opening turn from /series. There were three consent mechanisms on this path and only one of them was real; the prose was spending the feature to duplicate a control the SDK already enforces. Rewriting those four lines took it to 21/30 on the opening turn and 12/12 on gate 9's three-turn script. DECISIONS.md 2026-08-22 carries the full bisection, including the five hypotheses that measured 0/5 and the harness bug that made an earlier fix look like it worked. Gate 9 of scripts/decke-gates.mjs pins it.

The card the reader answers is segmented by variant provenance. A held log_cards gets a real dry run before hold eligibility is decided, whose rows the server streams as a data-decke-approval-preview part keyed by toolCallId — every field from a real invocation of the real handler with dry_run forced, because a fabricated row on a consent dialog is a fabricated authorisation, and no chip is emitted for it because that work happened for the dialog rather than for the reader. The card then has two sections — what he knows, and "what was the variant on these?" — and no confidence number: miscalibrated model confidence measurably degrades decisions, where provenance is a fact that cannot be miscalibrated. Classification keys on candidate count, not resolution status, because an omitted variant on a multi-printing card resolves successfully to the primary, so a status-keyed field would file exactly the row worth asking about under "known". pickVariant's silent-default semantics are unchanged and pinned by a test; the classification is a new field beside it.

Every other held write gets the same keyed preview, read-only: its summary is the dry run's operation lines with the model-facing preamble and re-run instruction removed (previewSummary in adapters/aisdk.ts), and the card draws them as rows with the catalogue's own card name and art (DryRunList). A save_deck reconcile is therefore approved against the cards it will remove and re-count, not against the bare "DRY RUN — nothing executed" header it used to show.

That card cannot be expressed through the approval protocol above, and this is the load-bearing consequence: the SDK signs over the held input, so any client-side edit invalidates the signature by construction. So there are two paths. An unedited accept — the common case — takes today's signed path unchanged, every existing property intact. An edited accept never touches the held arguments: it commits a corrected batch from the browser through POST /collection/batch (the reader's own JWT, the same endpoint and the same RLS as the rip flow — no new authority, and see SECURITY.md), and only then settles the held call approved: false with a reason built from the real response, which convertToModelMessages turns into execution-denied so his account of the turn stays true. Commit-then-settle is correct by discipline rather than by primitive, so the ordering is pinned by a test. The idempotency key is scoped to the held call rather than to content, because a caller-supplied key is honoured unbucketed and unbounded and a pure-content key would have made the second identical correction anyone ever made write nothing while reciting the first one's numbers as fresh. editable: false is the safe answer and is taken often — any tool that is not log_cards, a dry run that failed, a structured row with no certainty field (the rolling-deploy case: new browser, old server) — and conversational APPLY now refuses approval for those cases. Candidate-bearing ambiguous printing rows are the deliberate exception to "fully resolved": they are actionable through the existing picker, whose edited path denies the signed original and commits the reader's corrected batch.

One behaviour change worth knowing before someone files it as a bug: a card with more than one printing and no stated variant used to be silently resolved to the primary and written. It is now asked about, and not written if the question is ignored.

A write reaches the page behind him. Page data is TanStack Query, fresh for five minutes, so a write he made used to leave the deck page showing the old list (and its absolute-quantity steppers able to write it back). useDeckeChat now invalidates query roots when a write's chip finishes, whether the server ran the tool or the card committed a corrected batch. The roots come from chat/writeRefresh.ts, one entry per tool that can ask. A test fails when a write tool has no entry, or when an entry names a root no query uses.

15f. Fabrication is bounded, not cured

Nine defects were found by deploying this branch to a preview and asking it real questions. None were caught by tests — 88 API tests, 183 web tests, a clean typecheck across nine workspaces, CI, CodeQL and an adversarial review were all green — and several could not have been (DECISIONS.md 2026-08-22, "Nine defects that only a deployment could find"). What they share matters more than any one of them: wherever a tool's output cannot answer the obvious next question, the model fills the gap. An empty result under a filter that could never match reads as "not found" rather than "wrong index"; a summary that names cards without returning their ids invites the caller to invent the missing key.

So the fixes are in the tools rather than the prompt, and because the tools live in packages/agent-tools (§10) the MCP front-end gets them in the same commit. search_cards now leads its description with what it does NOT match — query matches CARD names, never a set name, however many ways you spell it — and, on a set_id no set has, says in as many words that an empty result is not evidence the card does not exist. collection_summary returns ids. set_progress's unknown-id error names the recovery. This is a better frame than "the model is unreliable", because a contract gap is findable, fixable and testable, and an instruction not to guess is none of those.

And the fix belongs in the shared helper, not the caller that reported it. resolve.ts compared a card reference's set_id raw, so search_cards understood sv3.5 and add_cards/log_cards did not — a bug that works wherever it is tested and fails wherever it writes. get_card had been patched individually, which is exactly how a fix stays half-applied. The same shape appears once more in the name field: a rarity written into it (Tatsugiri Illustration Rare) matches no printed name, returns nothing, and is read as "that card does not exist" — after which a price gets quoted from memory. Both are now handled inside resolve.ts and entities.ts, so the next tool to take a card reference gets them without knowing they exist. The rarity vocabulary is read from card.rarity rather than hardcoded, because it grows with every set.

That unknown-set result is a fail, not an ok, and the difference is not cosmetic. grounding.ts collects the card ids tools actually returned this turn, and screens.ts's sanitizeScreen drops any id in a grid that was not among them — the control on the defect where he drew five card ids the account does not own, twice, with different ids each run. It is a Set lookup, no model call, sub-millisecond, chosen over chain-of-verification and self-consistency on cost for a latency-critical path. But grounding.observe harvests card-id-shaped tokens from every SUCCESSFUL tool result, and the unknown-set message echoes the model's own guessed set_id back at it, which is right — it needs to know which id was wrong. A guess of sv1-25 is card-id-shaped; through ok it would have grounded ITSELF, and a grid built on it would then have been waved through. Errors are excluded from grounding, and fail is the honest shape anyway. With nothing observed at all, everything passes: this is a check for CONTRADICTED ids, not for unproven ones.

He still narrates. Asked to add 4000 of a card, the deployed preview emitted <express><commands><op>state</op><value>alert_dizzy</value></commands></express> as ordinary visible text and produced zero data-decke chunks — a character reading his stage directions aloud while standing perfectly still. apps/api/src/decke/narration.ts strips that syntax out of visible text; the stream transform moved there out of api/chat.mjs, which keeps only a console.warn, because a Vercel function is untyped, unimportable and driven only by a live deployment, and the ordering bugs were in exactly that half (the held tail was flushed under a literal id: 'narration', naming a block no text-start ever opened — our own reader concatenates and does not care, a conformant one drops it, and what it drops is the end of a real sentence). The filter is deliberately narrow, anchored on our own tool names in any namespace and in the name="…" attribute form the gate suite caught on the preview, so <b> and 10% < 15% survive. That list is DERIVED from COSMETIC_TOOLS in tools.ts rather than written out. It was a hand-written seven while buildTools exposed nine, so a leaked <journey> or <xai:escort> walked straight past the filter for as long as those two tools had existed — no type error, no failing test, and the only symptom a reader seeing markup in a speech bubble (issue #90). tools.test.ts now pins COSMETIC_TOOLS to the tools that factory actually returns, and narration.test.ts asserts every one of them is stripped in all three shapes, so a tool cannot be added without the filter learning it. The 23 data tools and 4 deep tools stay deliberately out of scope: their names are ordinary English (decks, lists, health, revert) and the attribute rule strips a whole element on a bare name="…" match, which trades a leak nobody has measured for false positives on prose that happens daily. It also says plainly what it cannot do: four of five observed failures were not markup but bare prose — flyTo [data-decke-goal-switcher] point=true — and no pattern catches that without also eating sentences he is supposed to say.

The old response was to replace the only chat model after five prompt rewrites scored 0/5 on flyTo. That is no longer the architecture: models.ts now holds the code-selected Claude 5.5 tiers described in §15d, while narration filtering and the turn guards remain the deterministic bounds. Model routing is measured per pathway by the replay probe rather than inferred from a single aggregate chat score.

Per-step tool narrowing was retired on 2026-10-10. Production logs showed that it hid add_battle_log from a pasted PTCG Live log on step zero, and the old loop could end before the tool returned on step one. Its motivating measurement never replicated (the original bisection was 34 tools narrated 5/5, 23 tools 1/5, and 10 tools 3/5; the follow-up saw 0/24 on the trigger and found a harness bug). Changing the tools array between steps also rewrites Anthropic's cached tools prefix and can invalidate in-request thinking, while a signed approval card still gates every write; focus.ts now shows every real tool at every step except one made impossible by a spent meter cap. The evidence and decision are recorded in 2026-10-10-deck-e-logs-a-pasted-battle-every-tool-in-view-from-step-one-a-backstop-the-whol.md.

15g. The chat surface — a transcript, and a way to walk somebody there

The panel is the content pane. On desktop it occupies the space between the sidebar and the right edge, below the header; both stay sharp and usable. On a phone the scrim starts below the app header by offset, not by z-index, and that distinction is the mechanism rather than a preference: backdrop-filter samples whatever composites behind it regardless of paint order, so a scrim dropped below the header would still blur what is under it. The blurred element must not extend under the header at all. Both offsets come from custom properties AppShell publishes — --app-header-h and --app-sidebar-w — because the only thing that knows the sidebar's current width is the component that collapses it, and a custom property reflows on its own where a measured rect needs an observer and a duplicated number drifts. The panel itself is glass and pointer-transparent on both platforms; the composer is the opaque thing, a card rather than a pill floating on the scrim, and it takes max(12px, env(safe-area-inset-bottom)) as his park box does, or padding the composer up without moving his mark would break the relationship that puts him beside the input.

A turn is an ordered list of parts. ChatMessage is { id, role, parts } where a part is text, a tool row or a screen; text and tools are derived by helpers rather than stored. Three parallel arrays had no order between them, so a lookup that happened halfway through a sentence rendered above the sentence it interrupted, updating a chip filtered-and-appended so every settled row moved to the end, and only one screen per turn was expressible. An update is now an update in place, which is what lets movement tools emit rows from their real results and lets rows interleave with prose in occurrence order.

Recent work is replayed as evidence. For the six newest assistant messages, finished calls return to the model as real tool parts with outputs capped at 12,000 characters; denied writes return as output-denied. Older messages keep their compact lookup record. The browser and server share a bounded 40-message, 160,000-character history window, while the current turn has its own 240,000- character cap.

Activity is one live line, not a tool-row stack. It shows the active tool's icon, present-tense status and elapsed time, expands to the completed steps, and keeps a compact summary above the reply. Research exposes its sources and showDeck renders a checked, saveable deck widget; animation follows the activity rather than returning to idle during tool calls. Partial and error steps remain explicitly labelled, and a turn with no reply gets a real fallback.

A clarification is a docked card, not prose disguised as a tool result. The ask_user tool emits one to four bounded multiple-choice questions; AskCard occupies the composer dock after the stream settles and offers the listed choices plus Other and Skip. The completed ask is replayed in history as its real tool-ask_user part so the next turn retains what was asked. The reader's answers themselves are sent as the next ordinary user MESSAGE, because Claude Haiku/Sonnet 5.5 may distrust and ignore user-authored words nested inside a tool result. The card is therefore an interaction boundary, not permission to turn reader text into trusted tool output.

Closing the chat ends the turn. It aborts, settles any pending approval as a denial — the correct reading of walking away from the question — and records on the transcript that it was stopped. Letting a turn run invisibly is worse than it sounds, because a turn can navigate: the page would move under someone who has just said they were done, with no surface left to explain why.

Markdown is rendered under a URL and image allowlist. lib/markdownSafety.ts is shared by the chat renderer and routes/deck/MarkdownView.tsx, which draws the guide Deck-E's own deck_strategy tool writes. Links are limited to http/https/mailto plus relative, and no remote image is ever fetched — the alt text is shown instead. SECURITY.md carries the reasoning.

A journey is one plan, executed in the browser. The journey tool takes an ordered, capped step list (say, goTo, flyTo, highlight, click, ensure; at most ten) and character/host/journey.ts runs it as a timeline — one leg, not one model turn per hop, which is possible at all because the selectors are constructible from ids the data tools return before anything moves. Steps take landmark references, never free CSS: a free selector is a capability, and the plan is validated at parse time so a bad one is refused whole before step 0. There is no wait verb and no duration field — every step that names a landmark waits for that landmark, bounded, because a fixed delay after a click is wrong on a slow connection and making it inexpressible beats a rule against it. ensure exists because the determinism premise is false: on /series the uncollected series appear only after a one-shot disclosure. A trusted-event guard is load-bearing, since the sequencer performs its own clicks and without isTrusted the first would cancel the journey running it; a real click or scroll from the reader does cancel it, and it says where it left off. A hidden control is still a clickable control — below the nav breakpoint the sidebar links are display: none but present — so a step that needs him to be SEEN refuses a target with no box. A journey that stops half way is partial, not error, and its summary is built from what ran rather than from what was planned. Cost is the argument: a four-leg escort re-bills ~17k prompt tokens, one journey leg ~5.1k.

Navigation invalidates his state. The character host subscribes to the route: the speech bubble retires, the minimised bar clears, and a park anchored to a selector on the page just left is re-solved. A journey step is exempt via an explicit flag rather than via travelling, which only means "a UI tool moved him at some point this turn" and would therefore have exempted the case the owner actually hit.


The write path (revised 2026-08-19)

Two shapes of collection write, deliberately:

per-variant batch
endpoint PATCH /collection/variants/:id, POST …/increment POST /collection/batch
caller the web UI's counters and steppers (absolute PATCH only, via lib/collectionWrites.ts); …/increment is for agents log_cards, imports
transaction one per variant ONE for the whole batch
progress recompute one per call one per DISTINCT SET
idempotency PATCH is idempotent by being absolute; …/increment has none (a human pressing again means it) keyed

The batch endpoint exists because the per-variant shape does not scale to a pack-opening haul. Driving it in a loop from the MCP cost 0.65 s per item in production — two SQL round trips to resolve, one HTTPS hop, one transaction, one full-set CTE — which put a 99-item batch past the serverless wall clock and produced the 2026-08-19 silent-success incident (DECISIONS.md). The batch path is ~25 round trips instead of ~1200.

Its statement order matters and is load-bearing:

  1. insert the idempotency row — first, so a duplicate collides before anything changes;
  2. materialise collection_item placeholders at quantity 0 — Postgres cannot lock a row that does not exist, and "I just pulled this card" is exactly when it does not; without this, two concurrent batches both read 0 and both write 1, losing a delta;
  3. lock, ordered by card_variant_id (as is step 2's source list, so two overlapping batches cannot deadlock);
  4. compute in JS from the locked values;
  5. one multi-row UPDATE, one first-acquisition query, one event INSERT;
  6. recomputeSetProgress per distinct set, ordered by set id;
  7. explicit COMMIT before the response (commitRequestTx) — the RLS middleware otherwise commits on res.on('finish'), i.e. after the response has flushed, which for this endpoint would be the incident's own failure mode in miniature.

The mutation log

mutation_batch (one row per operation) and mutation_event (one row per thing changed, with before and after) sit alongside collection_event rather than replacing it: that table is the collection's activity feed and predates this by seven migrations. collection_event.batch_id joins them, so one row can answer both "what happened to this card" and "which operation did it belong to".

mutation_event is append-only at the policy level — SELECT and INSERT, no UPDATE — so a revert appends compensating events rather than editing history. See SECURITY.md for why that matters on Supabase specifically.

Administration, lifecycle and AI usage (2026-09-15)

Migrations 068–071 extend the immutable 064–067 foundation. Every account has one canonical admin_account.role_id, default User. Built-in tiers are User 10, Superuser 20, Contributor 30, Admin 40, Superadmin 50 and Owner 60; custom roles are limited to10/20/30/40 with enforced permission ceilings. Owner authority derives from protected canonical membership seeded from trusted bootstrap state. It is not an editable permission or a synonym for Superadmin.

Assignment checks the actor, destination and current target tier under the governance lock. Definitions distinguish protected identity from safe editing. The old junction is archived privately; a read-only projection and deprecated single-summary roles[] preserve older readers, projecting Owner as existing Superadmin only there. New authorization uses singular role/isOwner. Ambiguous mappings fail the migration transaction for explicit operator review.

One SQL feature resolver derives product permissions from lifecycle and opt-in: released for active users, beta by opt-in for every tier, experiments by tier 20+ opt-in with automatic Superadmin/Owner access, disabled for nobody. Scanner and Deck-E initialize experimental. Opt-in, character visibility and sharing consent are independent. Dev tools is a separate permission-filtered directory; Contributor keeps ordinary personal self-service without Administration.

Owner per-user overrides are revisioned/audited and independent of role. Null markup inherits; zero remains valid. Unlimited means a zero-debit reservation, not a synthetic balance grant. New reservations require current policy; existing reserved work and bounded children retain immutable pricing. Access, debt, refund/dispute holds and operational limits still apply. Flat quoted settlement and existing financial recovery/reconciliation are unchanged.

The chat server durably accepts a parent request before provider work. The first credit start and provider-attempt row commit atomically after current authority and payment holds are checked under the relevant locks. The SDK call sets a synchronous invocation latch. A known cancellation or lost database acknowledgement before that call retains an exact-operation compensation path; it cannot refund completed/failed invocations or an earlier real retry attempt. AI SDK middleware instruments actual local calls and nested/retry/fallback attempts; finalization records safe status/tokens and reported decimal cost or unknown. It does not persist whole SDK callbacks, tool payloads, private context or raw errors. Full server SHA and strict preview PR ID, falling back to merge subject, identify the generation build. Unreported upstream work remains unknown.

An import repair uses the same wallet ledger lock as chat to debit one paid credit at admission. The provider call runs after admission commits; settlement records its measured fractional charge and atomically returns unused hold capacity. A suspended account cannot start a repair, but its already admitted request can settle once. Candidate lookup fetches one row beyond its 400 printing limit and leaves a truncated line for the reader instead of inferring a gameplay identity from an incomplete set. The route retries settlement when its acknowledgement fails; a later wallet read releases any hold still unsettled after 15 minutes and marks the usage abandoned with unknown cost.

Usage reads require an active application session, tier 40 or higher and current admin.access. Custom roles lose metadata and shared-content access immediately when that permission is removed; the capability projection uses the same rule.

Consent is default-off. First-leg exchange consent plus current enabled same epoch and active account are required on every administrative content read. Withdrawal deletes optional excerpts and re-enable cannot resurrect them. Usage metadata survives missing browser saves; history correlation is validated against owned server acceptance and cannot forge build/cost. Own-history deletion withdraws excerpts while preserving metadata. No-store responses and sensitive query clearing/refetch complement server checks; previously viewed text cannot be recalled.

The shared DataTable keeps server filtering/paging or complete-list sorting at the caller and uses semantic rows at desktop/mobile widths. Sort indicators and core controls use the shared SVG Icon with accessible labels. Administration, feature preferences, Dev tools, usage and cost observations reuse that system. Observed complete/unknown samples inform an explicit estimate draft and pricing revision save; actual provider cost never silently changes a wallet charge.

ADMINISTRATION.md documents the controls; API.md gives DTOs, SECURITY.md the trust boundaries, and DEPLOYMENT.md the schema-first mapping/rollout runbook. Local fixtures do not establish live Stripe delivery or production readiness.

Labeler pending-photo queue

The labeler's object-store queue groups an original timestamp ID and its HEIC-to-JPEG replacement into one logical photo. Reads, repair, cleanup, and discard take the same per-original database advisory lock; cloud uses a dedicated worker pool capped at three connections so a request disconnect cannot release the lock before storage work finishes. Queue work waits in a FIFO before database checkout. Self-host queue work uses one shared request-pool connection. The listing is one object-listing snapshot: each family it saw with a photo appears once under the original ID, as the replacement if listed, else the original, with the listed size; only sidecars are read after it, lock-free, at most 8 at a time, with a short retrying timeout, and a sidecar whose listed etag matches bytes already read is not read again. Families seen only as sidecars are rechecked under the lock, two at a time. Reads resolve whichever photo survives now, preferring the replacement. Metadata alone is never evidence that a photo exists. The browser's IndexedDB outbox continues to hold photos that have not uploaded; it does not decide which server photos to hide. See the queue state contract for partial writes, deletion failures, and two-device interleavings.

Last updated by Claude Opus 5.5 on behalf of @cheyras -- 2026-10-12

Clone this wiki locally