Source docs: docs/runbooks/uma-adapter.md, docs/runbooks/uma-local-demo.md, docs/runbooks/local-testing.md — this day restructures the UMA runbooks by environment.
docs/runbooks/uma-local-demo.md (with an added
local-vs-staging comparison table and “why a mock exists” framing); uma-adapter.md
keeps a one-paragraph §4c pointer, gains a “which document do you need?” note up top,
and §5 was renamed to “Testing against a Sepolia fork” with its false premise (“there
is no mock deployment to point at”) rewritten — the mock exists now; the fork’s job is
UMA conformance, not adapter mechanics. Code comments in UmaOraclePanel.tsx and
uma-demo.ts repointed. Stale test count fixed (53 → 63, verified via forge test).claude/uma-docs-split, uncommitted./config expected umaAvailable: false, and §5.4’s
API test expected a 400 that now returns success.DeployMockOracle → ChainConfig → umaAvailable: true, umaOracleMock: true),
with a re-seed note for DBs predating the mock; §5.3 binary-only is now a visual
check; §5.4 fires the two remaining 400s on anvil and expects valid UMA creation to
succeed; §5.6 positive case points at uma-local-demo.md (mock) vs. uma-adapter.md §5
(real-oracle conformance); §8’s “not testable on anvil” row narrowed to real-oracle
behaviour only./config: umaAvailable: true,
umaOracleMock: true). Same branch, uncommitted.isBinary string-matches lowercased sorted labels, so any typo or
synonym silently disables UMA. A checkbox that sets the canonical labels removes
the typing instead of validating it. Unchecking restores whatever was typed before
(stashed in a ref) so exploration isn’t punished. No Checkbox component exists in
the UI kit — a styled native <input type="checkbox"> avoids adding one for a
single use.CreateClient.tsx — binaryChecked state + toggleBinary; checked
fixes outcomes to ["Yes","No"] and swaps the input list for a one-line note
(“the labels the UMA oracle option requires”); unchecked restores the stashed list.
Existing isBinary/UMA-card logic untouched — the checkbox only feeds it the right
labels.tsc --noEmit clean on @verex/web. One check now yields a
UMA-eligible binary market; multi-outcome flow unchanged. Branch
claude/uma-docs-split, uncommitted (separable from the docs changes at commit
time — different files).closesAt is never enforced — markets stay tradable past their end timemarket.status !== "OPEN"
(book.ts:255), and nothing ever flips status by clock: no scheduled job reads
closesAt, no order-time comparison against it exists. After creation-time
validation (“must be in the future”), the API never reads the field again; the web
only renders “Closes closesAt at order placement (cheapest, no job needed) and have the UI show
“closed” past the date.closesAt is informational. Real platforms halt trading at the event time, so this
is a divergence worth a deliberate decision rather than an accident. Closed the
same day — next entry./orders and
/trade both funnel through placeOrder), needing no scheduled job; markets
become closed-but-unresolved until resolution, which is the correct intermediate
state. The oracle panel note turns the two-independent-clocks surprise into an
explanation shown exactly where the confusion arises, and only while it can arise
(pre-cutoff, Requested/Proposed states). Demo walkability kept: propose stays
enabled early — the note frames it as the premature-proposal setup for the dispute
scenarios.book.ts placeOrder rejects with 400 `market closed for trading at
tsc --noEmit clean on both packages; the displayed close date now
means what it says. Branch claude/uma-docs-split, uncommitted.packages/api/.env carries both.TELEGRAM_NOTIFY_LOCAL=1) for testing the hook itself locally.telegram-notify.ts returns early when CHAIN_ID === 31337 unless
the opt-in is set.tsc clean; staging/prod behaviour unchanged (their chain id is
11155111). Branch claude/uma-docs-split, uncommitted..env.staging — real keys were one git add -A awaypackages/contracts/.env.staging as
untracked, not ignored — a 3.4 KB twin of .env holding the operator private
key and the Alchemy URL..env, .env.local,
.env.prod, …), so every new suffix silently defaults to committable —
deny-by-default with explicit allows is the only version of this rule that can’t
rot. Templates must stay tracked, hence the ! re-allows..gitignore — .env + .env.* (and the same for
scripts/deploy.env*), with !.env.example / !.env.*.example /
!scripts/deploy.env.example.check-ignore: .env.staging, .env, .env.prod ignored;
the four committed *.example files still tracked. Nothing secret was ever
committed — the file was untracked the whole time.[6] posting MM ladders:
seed failed: market closed for trading at 2026-07-10T00:00:00.000Z. The new
placeOrder cutoff rejected the operator’s own opening quotes on markets whose
hardcoded seed dates are now in the past. Staging was left half-seeded (markets, no
books) on the previous revision — the deploy aborted before building the image.postLadders guarded status !== "OPEN"
but not the cutoff, so it quoted a market that cannot accept orders — a closed
market having no book is a state, not an error, so the guard belongs next to the
status check rather than as a try/catch at the call site. (2) The seed’s dates are
absolute literals written months ago, so real time walks past them: today 9 of 32
markets are closed (kr-world-cup-quarterfinals-2026, ai-imo-gold-2026, and all
7 members of mlb-home-run-derby-2026). Left as-is that ships a demo where a
quarter of the markets are inert. Not silently “fixed” by rewriting the questions’
dates — a World Cup market closing in December is nonsense, so the real fix is
relative-to-run-time dates, which is jay’s call.mm.ts postLadders returns early past closesAt; seed.ts step 6
skips those markets and prints which ones, so an inert market is never a silent
omission.db:reset green again, listing the 9 skipped slugs. My process
failure worth naming: I enforced the cutoff and deployed without re-running the seed
— the one command that exercises order placement across every seeded market.umaPropose/umaFinalize now take accountIndex (propose was
hardcoded to the operator), shared requireAccount validates 0..9 with a
juryOnly flag rejecting index 0 for votes; routes pass it through. Web —
UmaOraclePanel reads useWallet(): propose/dispute/finalize act as the selected
wallet with the wallet named on the button, the five dispute buttons collapse to
one, jury rows become a read-only tally where only your own row offers Vote
buttons (highlighted “(you)”), plus a line explaining one-vote-per-address and the
operator’s exclusion. api.ts helpers gained the parameter. Runbook
uma-local-demo.md rewrote scenario 1 as a be-this-wallet/do-this table and added
the curl equivalents.tsc --noEmit clean on both
packages.getT() on
the server, useLocale() on the client, and ko: Record<MessageKey, string> making
a missing Korean value a compile error. The work was inventory-and-convert across
~2,500 lines, so it went to five parallel agents on disjoint files, each with its
own key namespace (home. market. portfolio. create. uma./group.) and
forbidden from editing lib/i18n.ts — one owner for the dictionary meant no write
conflicts, and the type system caught anything they got wrong on merge. Two rules
they were held to: never translate values that cross the wire (category filter
values, the literal Yes/No outcome labels the isBinary check string-matches,
oracle state enums), and route displayed enums through a lookup instead.lib/i18n.ts; 14 components/pages converted,
including hardcoded toLocaleDateString("en-US") calls now taking the locale-aware
intl. GroupView was included though not requested — the group page is one click
from home and a half-translated path is worse than either extreme. Also fixed a
pre-existing inconsistency the audit exposed: the market page rendered
market.category raw while the home page ran it through categoryLabel().tsc --noEmit and next build clean; verified against the production
build on a spare port — 13 Korean and 6 English assertions across all four pages
plus the group page. A leftover-English sweep found only what should stay English:
market titles, seeded rules copy (“Settled by UMA’s Optimistic Oracle…”), and
Condition (CTF protocol term, deliberate).pnpm run build inside
packages/web overwrites the same .next the dev server is using, which 500s the
running dev server; clearing .next then left it 404ing until restart. Verify
against next start on a spare port only when no dev server is running against the
same directory — or accept that the dev server needs a restart afterwards./wallet/N took 5.0s on staging, and during those 5s the page displayed the
previous wallet’s balance and positions.walletSummary calling balanceOf once per outcome
per market — ~64 sequential RPC round-trips before the page could render anything.
The CT is ERC-1155, which has balanceOfBatch, but the SDK’s hand-rolled ERC-1155
ABI only declared the single-item call, so nobody could batch. Adding it makes the
whole portfolio one round-trip. The confusion was separate and worse than staleness:
the provider kept the old summary while fetching, so a wallet switch showed another
account’s money attributed to the account you just selected. Storing the summary
with the wallet it describes makes that structurally impossible; a rising request
id stops a slow response for an abandoned wallet from overwriting a newer one.
Chose skeletons over jay’s suggested hourglass — same signal, no layout jump, and
unlike a spinner they never imply the number underneath is still valid.ct.balanceOfBatch1155 + CTClient.balanceOfBatch; walletSummary
flattens market×outcome into one batched read; WalletProvider gains
{index, summary} pairing, a request id, and a loading flag; new
ui/skeleton.tsx; portfolio stat cards, position rows and activity rows render
skeletons while busy, with a spinner beside the section titles for refresh-in-place;
the nav balance too./wallet/1 5.0s → 0.09s, verified correct after a real trade
(111.11 tokens, cost $50, P&L +$17.70). Build renders 22 skeleton blocks and 2
spinners; the “no positions yet” empty state no longer flashes before positions
arrive — the most alarming part of the old behaviour. tsc clean on api/sdk/web,
SDK tests pass. Does not fix the settlement-lag inconsistency reported earlier
(chain balance vs DB cost basis); that one is still awaiting a decision.trade.ts walletSummary (pending netting, FAILED exclusion);
group-create.ts ×2 questionKey: verex:<slug>:<job.id>; seed.ts run nonce +
key; market-create.ts questionKey doc rewritten to say why the nonce exists./config didn’t even exist. A re-deploy per the
wrapper’s own rule (SKIP_SEED=1: code + migrations, keep data) brings everything
from PRs #15–#23 without touching prod’s seeded markets. Stopping staging: the
Cloud Run services cost ~nothing idle; the 24/7 cost is the verex-db Cloud SQL
instance, so that is what got stopped — reversibly (--activation-policy NEVER),
not deleted.SKIP_SEED=1 ./scripts/deploy-prod.sh (exit 0); then
gcloud sql instances patch verex-db --activation-policy NEVER → state STOPPED./config live (umaAvailable: false —
correct, no adapter recorded for prod), 10 markets kept, /wallet/1 0.57s,
Korean + English render, https://verex.jaylabs.xyz still 200. Staging API now
fails (DB stopped, expected); prod unaffected (separate instance). Restart
staging: gcloud sql instances patch verex-db --activation-policy ALWAYS, then
re-deploy or just wait — services are still in place. Note for later: prod shows
the same 9 past-close-date markets as “Trading closed” now that the cutoff is
enforced; the stale-seed-dates decision applies to prod too.__session before forwarding. The server never saw
verex-locale, so getLocale() fell back to English on every server render —
a domain-only bug invisible in all our *.run.app testing. Client components kept
switching (React state), which is why the site looked *partially translated
there rather than plainly broken.LOCALE_COOKIE renamed to __session with a comment explaining the
name is Firebase’s constraint, not a choice; localStorage keeps the descriptive
verex-locale key (existing saved preferences survive; the old cookie is simply
ignored and localStorage restore re-writes the new one).__session=ko → Korean docs,
__session=en → English, old cookie name ignored. Deployed to prod and verified
through the domain itself. Lesson recorded: anything cookie-driven must be tested
through the Firebase-fronted domain, not only the run.app URLs.flex h-14 row (SiteNav.tsx); the three nav links
(Docs / Create / Portfolio) carried no whitespace-nowrap, so under horizontal
pressure the Korean label 마켓 생성 (it has a space) wrapped to two lines and blew
out the row’s vertical alignment. English labels are single tokens, so they never
tripped it.Button component already sets whitespace-nowrap, so the Faucet
button was safe — only the raw <Link>s were exposed. Fixing the wrap alone isn’t
enough: if the links can’t wrap they must be allowed to keep their width, so the
flex-1 search box needs min-w-0 to yield space first, and the links/wallet box get
shrink-0 so the search absorbs the squeeze instead of them. Minimal CSS-class-only
change — no markup or logic touched, so it can’t regress English.SiteNav.tsx — whitespace-nowrap shrink-0 on the three nav <Link>s,
shrink-0 on their icons and the wallet box, min-w-0 on the search <form>.tsc --noEmit clean on @verex/web. Branch
claude/fix-nav-korean-layout, uncommitted at time of writing. Not yet
visually verified in a browser (no headless browser available locally; the app needs
API+DB to run) — recommend a quick pnpm --filter @verex/web dev check in Korean
before the prod deploy. (Merged as PR #25 and deployed to prod the same session.)sticky top-0, which pins only
vertically; any horizontal page overflow scrolls the whole body — header included —
sideways together. Driving the live prod site with Playwright (Chrome, __session
cookie for locale) and measuring scrollWidth − clientWidth across widths pinned the
source precisely: no overflow at all on desktop (≥768px, both locales, every page);
overflow appears only below ~725px and the sole culprit is the nav’s right cluster
(ml-auto flex … gap-2, ~609px, can’t shrink). The prior day’s shrink-0 fix had
converted the old “wrap” into “overflow.” Realistically this bites via browser zoom
(which shrinks the effective viewport) or a split/narrow window, not just phones.lg; the wordmark text, wallet balance, and locale label hide below
sm (all non-critical or shown elsewhere). Confirmed CategoryTabs was a false
positive — a precise “not inside an overflow-scroll ancestor” check showed it sits
within the viewport and scrolls internally; the residual sub-414px overflow is still
the nav’s wallet <select>, and only in English (“Demo Wallet 1” > “데모 지갑 1”).
Stopped there rather than truncate core wallet text — chasing the last 40px on
360–413px English phones is over-engineering for a demo that isn’t phone-first.SiteNav.tsx — hidden lg:inline label spans on the three links + Faucet
(Faucet gains aria-label since its text can hide); hidden sm:inline on the wordmark
text and the wallet balance; shrink-0 on the logo link. LocaleToggle.tsx — locale
label hidden sm:inline.tsc --noEmit clean. Branch claude/fix-nav-korean-layout
(reused per the one-branch-per-request rule), uncommitted at time of writing. Known
minor: English portrait phones < 414px still show a small nav overflow from the wallet
selector width. (Merged as PR #26 and deployed; the residual is closed by the next
entry.)<select> — “Demo Wallet 1” is its widest option), and the
category tabs, an overflow-x-auto row with scrollbar-none, simply look cut on
phones — nothing signals there’s more to scroll.<select> is as wide as its widest <option>, and option text
can’t be restyled per-breakpoint — so render two selects sharing the same
state/handler: compact labels (Wallet {n} / 지갑 {n}, new nav.*Short keys)
below sm, full labels above. For the tabs, wrapping beats scroll affordances
(fade/scrollbar): every category becomes visible with no hint needed, and desktop
fits one line either way so nothing changes there.i18n.ts — nav.demoWalletShort / nav.operatorWalletShort (en+ko);
SiteNav.tsx — dual <select> (sm:hidden / hidden sm:block);
CategoryTabs.tsx — overflow-x-auto scrollbar-none → flex-wrap.tsc --noEmit clean. Told jay the phone needs a hard refresh — the
old page is cached on-device./categories endpoint returns orderBy: { category: "asc" }, so the tabs
rendered in alphabetical accident (Climate, Crypto, Culture, Economics, Politics…)
rather than by priority.CATEGORY_KEYS in CategoryTabs.tsx
already encodes the canonical order (matching the create-page dropdown): Politics,
Sports, Crypto, Economics, Tech & Science, Climate, Culture — its key order now
doubles as the sort order. Unknown (future user-created) categories fall to the
end alphabetically instead of breaking.CategoryTabs.tsx — CATEGORY_ORDER + exported sortCategories();
the nav renders sortCategories(categories).tsc --noEmit
clean. New branch claude/category-tab-order (new request).