Repository navigation
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 |
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.
- 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).
- 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.
- 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.
- Multi-user with row-level security. Every user sees only their own collection, decks, and lists. Catalog and pricing data is shared.
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.
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.
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.
Decision: Supabase Auth (email + OAuth) with JWT-based RLS enforcement.
- The SPA uses
@supabase/supabase-jsfor auth flows (signIn, signUp, token refresh, OAuth providers). - API requests carry the Supabase JWT as
Authorization: Bearer <token>. - API middleware verifies the JWT and extracts the user UUID (
sub). - Database queries execute with RLS context set per-request.
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).
The createApp pipeline (apps/api/src/index.ts) orders the base-path API
router (/api on Vercel, /deckpal/api self-host) as follows:
-
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(thenx-forwarded-for) identifies the client; outside Vercel, forwarded headers are ignored and the raw socket peer is used. Expresstrust proxyremains false. -
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.
/bugs12mb,/client-errors32kb,/dev/scan-queueand/dev/scan-flags4200kb (not a bare 4mb — a max-size upload's base64 form is exactly 4mb, with nothing left for its JSON wrapper),/decke2mb,/lists2mb,/decks512kb. 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 onappahead of everything (that parser used to shadow/register's and/token's own smaller ones the same way). The identity-free/client-errorshandler 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,/listsand/decksare 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'sjson.dumpsdefault) costs 6./decke,/listsand/decksare all sized at that ×6 worst case. -
Authentication and local identity —
authMiddlewareverifies 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. -
Session gates and per-user limits —
requireSessionrejects cloud anonymous callers (401) and PATs (403) for/tokens(20/min),/avatar(10/min),/oauth(30/min), the whole/adminsubtree (120/min) and the whole/me/creditssubtree (180/min)./bugs(10/hour, SEC-11) sits beside these but carries norequireSession— 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. - 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.
- 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//tokenhandlers (cloud-only, mintapi_tokenrows). Each now carriesoauthPublicRateLimit(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(separateapi/mcp.mjsfunction) is not part of this pipeline either, but it now carries two limiters (SEC-09): a global 300/min-per-instance admission counter beforeresolveToken(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 afterresolveTokensucceeds, keyed on the resolved, database-verifiedtokenIdrather 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.
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 readresolveIdentity (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.
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_idcolumn. 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 includesuser_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_profileis writable only in its avatar columns, and an avatar key can belong to one profile at a time (migration 072).
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.
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.
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".
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.
| 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.
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.
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.
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.
- Skip-if-unchanged is the first step of every job, gated on the upstream's own stamp.
-
captured_atis the source's timestamp, notnow(). Re-running a day's sync is a genuine no-op. - One transaction per group, a resumable cursor, and a
pg_advisory_lockso a manual run cannot race the scheduler.
The repo must work with plain Postgres + no Supabase. The Supabase integration is an additive layer, not a hard dependency.
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.
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)walksallTools()and callsserver.registerTool(...)per tool, translatingToolResultto MCP'sCallToolResult. 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'stool(), 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.
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.
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..maxof 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.tsandreport.tsturn 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/apiis 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/apifunction, with one 25 s budget for all matchups (under the RLS middleware's 30 s connection hold). The read-onlysimulate_battlestool inpackages/agent-toolscalls it for both Deck-E and the MCP server.@deckpal/simis built before the API in CI andscripts/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.
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 |
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.
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_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:
-
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_atsays 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 noamount_owed, nopaid_throughand no invoice history: invoices live in Stripe and are reached through its portal. -
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".
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.
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/matchingholds 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'sPIPELINE_VERSION—apps/web/src/scan/engine/frame.tsowns 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 iscard_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 theWHEREdrop it afterwards — fewer than k rows, with nothing in the plan to say why. -
Two confidences, never one.
POST /api/scan/embedreturns anidentityblock and avariantblock and deliberately nothing that could be mistaken for their average — nomatched, noconfidence. 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, infuse.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 (letTheNameQuestionTheKeyinresolve.ts). Where a key narrowed the world to candidates it cannot choose between —014/198is Steenee or Floragato — the vector's own top-1 agreeing with one of them makes the answer confident andresolvedBy: '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_exemplarrecordsresolved_byandphash_distanceso "does the hash ever change an outcome" — the question that decides its retirement — is aGROUP 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.
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.tsis 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.tsmaps a spoken printing onto the card's real variant kind slugs. -
actions.tsturns 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.tshides 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.tsowns 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.
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.htmlandconnect.html(vite-plugins/landing-prerender.ts). Each is that shell with the page's words in#rootplus its own title, description and canonical. The rootmiddleware.ts(Vercel Routing Middleware) rewrites/and/connectto them. A vercel.json rewrite cannot do this for/, because Vercel serves the realindex.htmlbefore applying rewrites. React replaces the words on first commit like any other page. -
Runtime metadata. For Google, which renders JavaScript,
lib/seo.ts'susePageMetagives 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.tsserves/sitemap-{pages,sets,cards,pokedex}.xmlfrom the live catalog (about 21,000 card URLs), listed by the static indexpublic/sitemap.xmlthat 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.
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 totheme.css(Lane A), and a JSON change-request queue drained by an agent with judgment (Lane B,design-requests/). The endpoints exist only whilevite devruns; they are absent from build output by construction, not by configuration. -
Deployed app: the route requires
design.viewfrom the server's current database permissions, including on preview. The compatibilitydesignEditorflag 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.
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.
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.
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
-
Nothing is fetched until somebody asks. In the app he is loaded by
DeckeButton'sonWarm— pointer-enter, touch-start, focus — and byonOpen, 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_Rootwith a pivot correction, not a camera move.setCharacterHeightdollies 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.tsandeyeSocket.tspremultiply their parent's inverse world matrix, the eye shader works in object space andlook.tssolves a ratio, so the factor cancels. What does have to know is anything measuring him in the world:screenRectand 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.tsholds a keep-out region whose bands the HOST measures from real CSS-sized elements (--app-header-hplusenv(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/deckeincluded) keeps the previous behaviour exactly. -
The host owns the media query; the engine owns the behaviour. Nothing in
character/decke/callsmatchMedia.DeckeHostreadsprefers-reduced-motion, watches it live, and passes it toDeckEas 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 inDECISIONS.mdfor 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;setStatealso takesdurationMsandthenso 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".idleis synthesized, because the playbook has no such state andbootwas standing in for it — badly, sinceboot's modulation freezes the float and the blink. 2026-08-20 inDECISIONS.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.tsrebuilds 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
curiouscame to drop 0.04 units and put them back once a second. A stepped channel still steps across the wrap, becauseconfused,frustratedand the alerts are authored in a robot register and that tick is the point. 2026-08-20 inDECISIONS.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.tsgives 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.
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 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:deckeThree 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).
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.
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.
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 anapproval-responded, approveddeep_thinkpart — the one shape ai@7'scollectToolApprovalshands tovalidateApprovedToolApprovals, whose HMAC check (approval id, call id, tool name, input) throws before any model call. An approved part anywhere else, or anoutput-availablepart 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.
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:
-
A bare
{type:'tool-approval-response', …}part.isToolUIPartinai@7.0.66istype.startsWith('tool-'), soconvertToModelMessagesread consent as a call to a tool NAMED "approval-response", with notoolCallIdat all, and the next leg died instandardizePrompt. -
A dropped
signature.experimental_toolApprovalSecretmakes the SDK sign each approval and verify it coming back;DECKE_APPROVAL_SECRETis set in Production and Preview, sovalidateApprovedToolApprovalsthrewInvalidToolApprovalSignatureError: missing signatureand 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.
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.
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.
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:
- insert the idempotency row — first, so a duplicate collides before anything changes;
-
materialise
collection_itemplaceholders 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; -
lock, ordered by
card_variant_id(as is step 2's source list, so two overlapping batches cannot deadlock); - compute in JS from the locked values;
- one multi-row UPDATE, one first-acquisition query, one event INSERT;
-
recomputeSetProgressper distinct set, ordered by set id; -
explicit COMMIT before the response (
commitRequestTx) — the RLS middleware otherwise commits onres.on('finish'), i.e. after the response has flushed, which for this endpoint would be the incident's own failure mode in miniature.
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.
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.
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