Skip to main contentSkip to data table
Pharos

Architecture

How Pharos is built: static Next.js export, Cloudflare Pages Functions, Worker API lanes, D1 storage, route ownership, and SEO metadata rules.

Agent navigation — Grep the heading you need: API Endpoints · Route Definition Model · Telegram Subsystem Tables · Telegram Alert Cron Job · File Tree Guide · Frontend Runtime And SEO Surface · CSS Build Pipeline · Coverage Subsystem · Worker Coding Conventions · TypeScript Target Constraints · Architectural Decision Records · Stablecoin lifecycle phases · Funding page.

API Endpoints

Curated architecture-significant routes. Start with the Documentation Index for the full docs map, or go straight to the API Reference for the exhaustive HTTP contract.

Route Definition Model

Static route metadata is declared once in the folderized shared/lib/api-endpoints/ module surface (@shared/lib/api-endpoints). That shared descriptor list carries path, method, admin/cache/probe/status-action metadata, shared dynamic-admin path matching, plus the worker dependency-hydration hints needed for static routes. Worker route primitives now live in worker/src/routes/shared.ts, domain route arrays are split under worker/src/routes/, and worker/src/routes/registry.ts composes them into the dispatch map that worker/src/router.ts consumes for method validation and generic dispatch. Dependency hydration lives in worker/src/routes/dependency-hydrators.ts and stays exhaustive/keyed by EndpointDependency, so adding a new dependency without wiring hydration still fails at compile time instead of silently defaulting.

Cron trigger metadata follows the same single-source pattern. shared/lib/cron-jobs.ts remains the schedule authority, while shared/lib/scheduled-runner-registry.ts binds each cron expression to a symbolic scheduled-runner key that both the worker scheduler and scripts/ci/check-cron-schedule-sync.ts consume. That keeps worker/wrangler.toml, shared cron metadata, and scheduled-runner dispatch in lockstep.

The architecture doc no longer carries a hand-maintained endpoint inventory. Use the generated OpenAPI artifact (public/openapi.json), the generated quick-reference block in API Reference, and the source route registries (shared/lib/api-endpoints/ plus worker/src/routes/) for current route membership. The architecture contract is the routing model above, not the full route list.

Telegram Subsystem Tables

TableDescription
telegram_subscribersBot subscriber preferences (chat_id, alert type flags)
telegram_subscriptionsPer-user coin subscriptions (chat_id, stablecoin_id)
telegram_pending_disambiguationEphemeral mid-conversation state for ticker disambiguation
telegram_pending_alertsOverflow subscriber-alert delivery queue drained by the 5-minute alert cron

The Telegram subscriber, disambiguation, and overflow-queue tables are part of the squashed worker baseline in worker/migrations/0000_baseline.sql; see worker/migrations/MANIFEST.md for the pre-squash lineage and current post-baseline files. For the full bot flow, see PharosWatchBot and Telegram Alerts.

Telegram Alert Cron Job

JobDescription
dispatch-telegram-alertsDetects DEWS/depeg/safety/launch/reserve/freeze changes and fans out alerts to subscribers on the dedicated 2,7,12,17,22,27,32,37,42,47,52,57 * * * * trigger

This is a curated subsystem summary, not the canonical Telegram schema or job inventory. Use PharosWatchBot and Telegram Alerts and the shared Telegram manifests for current tables, families, and commands.

File Tree Guide

This section is intentionally high-level. For the exhaustive current source inventory, run:

rg --files src shared worker scripts data functions
AreaPrimary pathsNotes
Frontend routessrc/app/**/page.tsx, route client.tsx, route layout.tsx / error.tsx filesStatic Next.js export surfaces. Route-specific contracts live in the route docs linked from Documentation Index.
Shared UI componentssrc/components/**, excluding shadcn primitives in src/components/ui/**Product components, charts, page sections, status surfaces, and stablecoin-detail modules. Preserve local design patterns before introducing new abstractions.
Frontend hooks and helperssrc/hooks/**, src/lib/**TanStack Query wrappers, stale/refetch policy, view-model builders, route metadata, API helpers, and pure UI derivations.
Shared runtime contractsshared/lib/**, shared/types/**, shared/data/stablecoins/**Runtime-neutral scoring, classification, endpoint metadata, cron metadata, stablecoin data, schemas, and types imported by both frontend and worker. Stablecoin metadata is authored in shared/data/stablecoins/coins/*.json; shared/data/stablecoins/coins.generated.json is the generated runtime aggregate. Legacy category shards were emptied and then deleted; npm run check:stablecoin-data guards against recreating those retired filenames.
API endpoint registryshared/lib/api-endpoints/**, worker/src/routes/**, worker/src/router.tsShared endpoint definitions drive method/auth/cache metadata; worker route arrays bind those definitions to handlers.
Worker API handlersworker/src/api/**Public, admin, messaging, and dynamic OG/API handlers. Exact HTTP contracts are canonical in API Reference.
Worker scheduled runtimeshared/lib/cron-jobs.ts, shared/lib/scheduled-runner-registry.ts, worker/src/handlers/scheduled/**, worker/src/cron/**Cron schedules, slot dispatch, leases, progress, domain ingestion/scoring jobs, freshness watchdogs, and reserve adapters. Run npm run check:cron-sync and npm run check:cron-connections for the authoritative schedule and connection-budget reports.
Worker support librariesworker/src/lib/**D1 helpers, auth, rate limits, circuit breakers, fetch/RPC helpers, stores, scoring support, request attribution, and runtime credentials.
Pages Functionsfunctions/**Same-origin site-data and ops proxy surfaces for Cloudflare Pages. Host/origin behavior is documented in Worker Infrastructure and Operator Origin Access.
Static/generated datadata/**, public/**, src/generated/**Build-time digest data, logos, redirects, public assets, generated docs metadata/sitemap dates, /llms.txt, public cemetery dataset exports, OpenAPI/Postman artifacts, and markdown exports. See Scripts for the generator/check commands.
Operational scriptsscripts/**, worker/scripts/**CI guardrails, smoke tests, static export serving, data refresh helpers, and worker-bound maintenance tools. See Scripts.
D1 migrationsworker/migrations/**Backward-compatible migration tree plus baseline lineage in worker/migrations/MANIFEST.md. Standard deploy applies migrations before the new Worker deployment.

The status contract surface is organized under shared/types/status/ by ownership: core health state, cron telemetry, D1 capacity forecasting, Telegram, yield/liquidity diagnostics, operational publication/dependency/canary state, response schemas, and public health/history. shared/types/status.ts remains the compatibility barrel used by existing frontend and Worker consumers, including lazy schema loaders. Domain modules import lower-level siblings directly and never import the compatibility barrel; internal schema primitives stay out of the public export surface. shared/types/__tests__/status-barrel.test.ts guards both the legacy runtime export list and schema-instance identity through the barrel.

Stablecoin catalog identity, routing, contracts, classifications, and other scalar metadata remain in shared/data/stablecoins/coins/*.json. Research-heavy domains may move as a unit into strict sidecars under shared/data/stablecoins/domains/: reserves, mint authority, GENIUS/MiCA compliance, and bridge/oracle/blacklistability risk review. scripts/lib/stablecoin-catalog-sources.ts validates each base source shape, claims whole-domain ownership for any present sidecar, merges deterministically, and then applies the full StablecoinMeta invariants. Generated registries consume only that merged projection.

Shared runtime host/origin defaults live in shared/lib/runtime-origins.json and shared/lib/runtime-origins.ts. Frontend API-base inference, /_site-data/* Pages Functions, ops-host Pages Functions, worker self/probe URLs, and local static-export tooling should consume that shared source instead of embedding production origins ad hoc.

The Stablecoin Cemetery public dataset export is static Pages data, not a Worker API route. scripts/maintenance/generate-cemetery-dataset.ts consumes the merged cemetery entry registry from shared/lib/cemetery-merged.ts, backed by curated dead rows in shared/data/dead-stablecoins.json and frozen tracked rows from shared/data/stablecoins/coins.generated.json. It writes public/datasets/stablecoin-cemetery.json plus public/datasets/stablecoin-cemetery.csv as an explicit maintenance artifact, with per-source checksums recorded in the JSON metadata; npm run check:generated-artifacts -- --only=cemetery-dataset guards drift in CI. Provenance pins the curated dead-stablecoin file and the buildFrozenCemeteryProjection() frozen-row projection — not the whole generated catalog — so live-coin curation cannot rotate a published cemetery checksum while no cemetery row moves.

The API integration artifacts follow the same static-export pattern. scripts/maintenance/generate-postman-collection.ts writes public/postman/pharos-api.postman_collection.json plus public/postman/pharos-api.postman_environment.json, and scripts/maintenance/generate-openapi-spec.ts writes public/openapi.json during prebuild; npm run check:generated-artifacts -- --only=postman and npm run check:generated-artifacts -- --only=openapi guard drift.

Worker cron refactors should place reusable stage helpers under worker/src/cron/shared/. The general abort vocabulary is throwIfAborted/rethrowIfAborted in worker/src/lib/abort.ts; cron families that need a terminal abort result (rather than a throw) build it beside their own stage runner, as sync-stablecoins does in worker/src/cron/sync-stablecoins/runtime.ts.

Frontend Runtime And SEO Surface

  • Indexable route membership is owned by src/lib/public-route-inventory.ts. PUBLIC_ROUTE_INVENTORY pairs every public path with a PublicRouteKind, and PUBLIC_ROUTE_PATHS is its flat path projection. src/app/sitemap.ts must agree with it exactly — src/app/__tests__/sitemap-frozen.test.ts asserts the two path sets are equal — so a hand-listed page has to be added to both the registry's primary/reference lists and the sitemap; the derived families (stablecoins, chains, taxonomy, mechanisms, case studies, blog, comparisons, digests, depeg events, public docs, methodology changelogs) flow from their own registries into both sides at once. /sitemap-tree/ renders its indexed-archive rows from the same entries, labelled by kind, for every path its hand-curated tiers do not already list (see Sitemap Tree Page). Route metadata modules and the route-specific docs linked from Documentation Index own per-route detail. Do not mirror the full route inventory here; use the registry and npm run seo:check for current crawlability coverage.
  • Legacy aliases are maintained through public/_redirects and are not sitemap entries: /telegram and /telegram/* redirect to /pharoswatchbot/; /mica and /mica/* redirect to /compliance/; /blacklist and /blacklist/* redirect to /freezewatch/; /report-cards and /risk-lab redirect to /safety-scores/; /peg-tracker redirects to /; /stability-index-alt redirects to /stability-index/; /tape and /tape/* redirect to /timeline/; /stablecoins/protocol/* (the Liquity-v1/v2 lineage paths) redirect to /stablecoins/infrastructure/*. This list is representative; public/_redirects is the source of truth.
  • Tool roots intentionally marked noindex,follow:
    • /portfolio/
    • /screener/picker/ (profile-driven shortlist; Pages-only; KV-backed snapshot pinning at same-origin /selector-snapshot/; see Screener Picker Page)
  • Public noindex utility route:
    • /pharoswatchbot/app/ is the Telegram Mini App control panel and is marked noindex,nofollow.
  • Tracked-variant browse ownership stays on the homepage query state (/?variant=...). The repo does not ship a dedicated /stablecoins/variants/* family.
  • Legacy numeric stablecoin URLs from the pre-canonical-ID era (/stablecoin/<DefiLlama id>/) redirect to the matching canonical /stablecoin/[id]/ route through functions/stablecoin/[[path]].ts.
  • Private operator routes marked noindex,nofollow:
    • /admin/
    • /admin-api/
    • /api/admin/
  • Crawlable server-rendered link hubs now live on the compare root, digest archive, depeg event archive, safety scores, liquidity, taxonomy landing pages, and stablecoin detail pages. These hubs are part of the static export and are what npm run seo:check validates for orphan routes, sitemap coverage, and click depth.
  • /llms.txt is regenerated in the Pages post-refresh artifact pass from checked-in route/data sources as a curated LLM-facing index. It is a community proposal/inference aid, not a robots or sitemap replacement.
  • Markdown content negotiation for agents is handled by functions/_middleware.ts for /methodology/, /stablecoin/<id>/, /changelog/, /digest/<date>/, and /docs/*. The .md variants are generated by scripts/maintenance/generate-markdown-exports.ts during postbuild and are written as out/<route>/index.md. Responses include Vary: Accept plus CDN no-store headers because Cloudflare's default CDN cache does not key on arbitrary Vary: Accept.
  • public/_routes.json uses a single broad /* include so exported document routes pass through that middleware, while static asset prefixes such as /_next/*, /logos/*, /dexes/*, and /featured/* stay excluded from function routing; Cloudflare rejects overlapping include splats, so the exclude list is the only lever. Cloudflare Pages static headers live in public/_headers, where the broad fallback Cache-Control allows CDN compression for HTML responses and static assets with their own cache policy detach the broad rule with ! Cache-Control so Pages does not comma-join duplicate values. The middleware also nonce-authorizes inline scripts and overwrites the CSP; that contract, the public/_headers fallback policy, and its route exceptions are owned by Security & Governance: CSP posture.

Runtime host and env rules

  • src/lib/api-url.ts is the frontend runtime source of truth for API origin selection; src/lib/api.ts re-exports those helpers and layers request/freshness handling on top.
  • src/lib/request.ts owns bespoke frontend JSON, text, blob, and raw-response lifecycles outside the endpoint-query registry. Its timeout covers response-body consumption, caller and timeout signals are merged, failures are classified, and RequestSequence cancels superseded UI requests and rejects late completions. TanStack endpoint reads continue through src/lib/api.ts and useRegisteredApiQuery(), with the query signal forwarded to the transport.
  • NEXT_PUBLIC_API_BASE is an optional explicit override, mainly for local next dev against wrangler dev.
  • When NEXT_PUBLIC_API_BASE is unset, buildRequestUrl() maps public browser reads on pharos.watch, ops.pharos.watch, stablecoin-dashboard.pages.dev, and *.stablecoin-dashboard.pages.dev to same-origin /_site-data/*, while buildApiUrl() still points explicit public-API callsites (for example feedback, API-key self-serve, and OG fetches) at https://api.pharos.watch.
  • functions/_site-data/[[path]].ts is the browser-facing proxy contract for the website data lane: same-origin browser reads go through /_site-data/* rather than to api.pharos.watch. The lane contract — allowed method and routes, the exact HTTPS SITE_API_ORIGIN requirement, origin gating, cache-age forwarding, and bounded body consumption — lives in Worker Infrastructure: Site-Data Auth.
  • site-api.pharos.watch is an internal Worker host, not a browser surface. worker/src/handlers/http/gates.ts allows GET allowlisted site-data paths plus the shared-secret header on that lane, and the exact POST /api/telegram-adoption mutation (or the corresponding Worker preview URL during CI rehearsal).
  • NEXT_PUBLIC_FORCE_SITE_DATA_PROXY is a build-time browser data-origin switch: when it is set to true, buildRequestUrl() sends public browser reads to same-origin /_site-data/* on any hostname, bypassing the allowlist above. .github/workflows/pages-release.yml hardcodes it to true, so the production bundle always uses the proxy lane and the host allowlist never decides there. It does not override an explicit NEXT_PUBLIC_API_BASE (that still wins and disables the lane), does not affect buildApiUrl() callsites that intentionally target the public API, and does not touch /api/admin/* requests, which stay same-origin regardless. Unlike the other NEXT_PUBLIC_* build inputs it is not a GitHub repository Variable, so flipping it requires a workflow edit rather than gh variable set.
  • NEXT_PUBLIC_GA_ID gates GA4 script injection in src/app/layout.tsx. When it is unset, the site still renders normally and no browser analytics events are emitted from src/lib/analytics.ts.

Metadata and crawl ownership

  • src/lib/page-metadata.ts is the shared helper for per-route canonical metadata, Open Graph images, Twitter cards, indexable robots preview directives, and sentence-aware description trimming.
  • src/app/layout.tsx owns the sitewide metadata baseline, icons, RSS alternates, and root JSON-LD (WebSite, Organization, Person, WebApplication) with stable #website, #organization, #person-tokenbrice, and #webapp anchors. It intentionally does not emit SearchAction until the site has a real query handler, and it does not preload GA because src/components/google-analytics.tsx loads analytics from the runtime component after the page shell is interactive.
  • Dataset JSON-LD nodes must remain crawlable in isolation for Google Search Console: emit explicit Pharos Organization objects for creator and publisher, a URL-valued license, a Pharos URN identifier, and sameAs where the dataset has a canonical page or public export. When a Dataset uses includedInDataCatalog, the nested DataCatalog reference must include the catalog @id, name, and url, not only an ID reference. Dataset distribution.contentUrl values must point only at public crawlable API/static-export URLs, never same-origin /_site-data/*.
  • src/app/sitemap.ts owns sitemap output for indexable routes. /compare/ and /compare/[slug]/ static comparison pages are included; /digest/ and every generated /digest/[date]/ detail page are included because daily and weekly digests are durable archive/citation pages with unique editorial text and snapshots; methodology changelog sitemap membership is the explicit METHODOLOGY_CHANGELOG_SITEMAP_PATHS allowlist. /portfolio/, /admin/, /admin-api/, /screener/picker/, /stablecoin/[id]/yield/, and /pharoswatchbot/app/ are omitted. /funding/ uses the latest of route edit time and checked-in funding data timestamps for lastModified. LAST_EDITED dates are auto-generated from git history (scripts/maintenance/generate-sitemap-dates.ts) and written to a gitignored JSON file plus its gitignored .d.ts type sidecar; locally npm run bootstrap:generated:history materializes it, and the production release regenerates it during prebuild from a full-history checkout. Public docs use scripts/maintenance/generate-docs-metadata.ts for git-derived first/last modified dates and the same gitignored JSON-plus-sidecar pattern, materialized by the same commands. Both generators fail fast on a shallow checkout rather than emitting filesystem-timestamp dates.
  • src/app/robots.ts publishes the sitemap location and disallows crawling of /admin/, /admin-api/, and /pharoswatchbot/app/; everything else stays crawlable. /admin/ and /admin-api/ are operator surfaces with host gates plus X-Robots-Tag: noindex, nofollow; /pharoswatchbot/app/ is a Telegram Mini App surface with noindex metadata/headers and Telegram frame ancestors, but it is not host-gated. The robots.txt block means crawlers can no longer observe those noindex responses — accepted tradeoff: already-indexed operator URLs cannot be deindexed via observed noindex while the disallow stands. Routes that rely on crawlers observing a noindex response (/portfolio/, yield subpages, datasets) remain crawlable.

Standalone PharosVille

PharosVille now lives in the separate TokenBrice/pharosville repository and is deployed through its own Cloudflare Pages project at https://pharosville.pharos.watch/. The Pharos.watch host keeps only temporary redirects from /pharosville/ and /lighthouse/ plus the shared API contract schemas that the standalone app validates against.

The standalone app reads Pharos data through its own same-origin Pages Function proxy. That proxy owns the PharosVille API key server-side and calls only the allowlisted public read endpoints on https://api.pharos.watch, so the host Worker does not need a CORS allowlist change for the split.

Pages Function endpoints (not Worker API)

These are same-origin Pages Functions backed by bindings that vary by function (ASSETS, KV, D1, Access/service-token configuration). They do not appear in the Worker API catalogue and are not part of the api.pharos.watch surface.

EndpointDescription
GET /_site-data/*Pages Function (functions/_site-data/[[path]].ts): same-origin website data lane. It validates the caller origin, signs the server-to-server hop with SITE_API_SHARED_SECRET, forwards allowed read requests to SITE_API_ORIGIN, and records site-data attribution when the DB binding is present.
POST /selector-snapshotPages Function (functions/selector-snapshot/[[path]].ts): projects SelectorInput, loads seven schema-validated canonical sources through the authenticated site-data lane in two fully consumed batches (four, then three), runs the shared selector engine, and stores a KV-attested schema-v3 pharos-verified output. The 100 KB cap is streaming; HMAC-IP rate/quota keys require a dedicated pepper.
GET /selector-snapshot/:sidPages Function: returns the KV-attested verified frozen projection or an explicitly client-unverified legacy projection, or 404. It recomputes the sid, returns 502 for corrupt/mismatched trusted values, and returns 503 when a first-read five-year retention extension cannot be confirmed.
GET /stablecoin/:legacy-idPages Function (functions/stablecoin/[[path]].ts): redirect shim for legacy stablecoin IDs before the App Router handles canonical detail routes.
GET /admin/*, GET /admin-api/*Pages Functions (functions/admin/[[path]].ts, functions/admin-api/[[path]].ts): operator-host asset gates for the Access-protected admin surfaces on ops.pharos.watch.
/api/admin/*Pages Function (functions/api/admin/[[path]].ts): same-origin operator proxy to ops-api.pharos.watch, using Cloudflare Access service-token bindings and the shared proxy helpers.
GET /safety-scores/map.jsonPages Function (functions/safety-scores/map.json.ts): GET/HEAD readiness manifest for the daily Safety Score map, read from the Pages-only SELECTOR_SNAPSHOTS KV binding with no-store. A missing binding or object is a 404, not a 500.
GET /safety-scores/map.pngPages Function (functions/safety-scores/map.png.ts): GET/HEAD map poster from the same KV namespace — latest on a short edge TTL, ?date=YYYY-MM-DD as an immutable dated archive. See Safety Score Map.
POST /pharoswatchbot-adoptionPages Function (functions/pharoswatchbot-adoption.ts): same-origin adoption-telemetry shim that forwards to the Worker's /api/telegram-adoption through the site-data lane, adding a dedicated IP-hash header.
functions/_middleware.tsPages middleware: markdown content negotiation on the negotiable route prefixes, canonical/noindex headers on direct .md asset paths, and CSP nonce injection on HTML responses — all before route-specific Pages Functions run. Host gating lives in functions/lib/ops-asset-host-gate.ts and the per-route functions.

Static feed route handlers

The App Router feed handlers under src/app/feed/**/route.ts emit static RSS/XML feeds from checked-in or generated build inputs. Treat that source tree, the sitemap, and npm run seo:check as the current feed-route inventory.


CSS Build Pipeline

Styling runs through PostCSS with the @tailwindcss/postcss plugin (configured in postcss.config.mjs). This is the Tailwind CSS v4 integration path -- there is no standalone tailwind.config file; Tailwind v4 reads design tokens and @theme directives directly from src/app/globals.css. The cn() utility in src/lib/utils.ts uses tailwind-merge for safe class deduplication at runtime.

Reminder: Tailwind classes must be static strings -- never construct class names dynamically, as the CSS purge pass cannot detect them.

src/app/globals.css also owns Tailwind v4 @source not exclusions for non-browser prose, data fixtures, and tests. Keep runtime class maps included, but do not let docs, scripts, worker code, or shared data text inflate the generated utility CSS.


Coverage Subsystem

The /coverage page model lives under src/lib/coverage/ as one module per feature (price, safety, dex, reserves, redemption, yield, flows, blacklist, mica, genius, dependency, mint-authority), plus a shared.ts with primitives (createStatus, createPresetStatus, createDataUnavailableStatus, resolveBooleanCoverageStatus). Each per-feature module owns:

  • The feature's preset table (when applicable).
  • The resolve<Feature>Coverage(...) function that maps a StablecoinMeta (plus auxiliary inputs) to a CoverageStatus.
  • A format<Feature>Breakdown(rows, breakdownMap) callback returning CoverageBreakdownItem[].
  • The statusKinds property on the exported coverageFeature object — derived from the module's preset table with statusKindsFromPresets(...) (plus any literal kinds the resolver produces outside the presets) — enumerating every kind the resolver can produce, used by the legend invariant.
  • The legendItems property on the exported coverageFeature object (backed by a module-private <FEATURE>_LEGEND const) — a list of CoverageLegendItem entries of shape { term, description, kinds[] } aggregated into the global legend.

src/lib/coverage.ts is a thin orchestrator + partial barrel — it re-exports the shared coverage types plus COVERAGE_FEATURES so consumers can keep importing those from @/lib/coverage, and it owns the two exported cross-feature helpers (buildCoverageRow, buildCoverageFeatureSummary) alongside COVERAGE_BADGE_TONE_CLASS. The headline/availability counters (countAvailableFeatures, countHeadlineFeatures, isHeadlineFeatureCovered) are module-private and reachable only through the counts buildCoverageRow puts on each row; per-feature resolvers, shared.ts status primitives, and the legend/status-kind tables are imported from their own modules rather than re-exported here. buildCoverageFeatureSummary calls feature.formatBreakdown(rows, breakdownMap) inline — adding a new feature requires providing the callback, otherwise TypeScript fails the build.

src/lib/coverage-features.ts wires each feature key to its per-feature module exports (formatter, status kinds, legend items); resolvers are wired directly in coverage.ts's buildCoverageRow via per-feature module imports, not through coverage-features.ts. src/lib/coverage-page-config.ts derives LEGEND_ITEMS from those per-feature exports plus a small fixed set of general entries (NR / Data n/a / —). The coverage legend invariant test in src/lib/__tests__/coverage.test.ts asserts every kind any resolver can produce has a matching legend entry. The former declared-vs-observed exhaustiveness test was removed: deriving statusKinds from the preset tables makes the declaration and the resolver output the same source.


Worker Coding Conventions

Loose-equality null guard (!= null)

The worker codebase deliberately uses != null (loose equality) as the standard null/undefined guard for D1 query results. D1 can return either null or undefined for absent column values depending on the query path and column type, and value != null catches both in a single check. This is intentional -- do not "fix" these to !== null or !== undefined.

Worker import boundary waiver

eslint.config.mjs enforces the worker/frontend/shared import boundary through its no-restricted-imports and pharos/worker-import-boundaries rules. The only named non-test waiver is frozen-invariants-lifecycle-registry-check for scripts/ci/check-frozen-invariants.ts, which imports worker and frontend registries to prove frozen stablecoin IDs were removed from lifecycle surfaces. Keep that waiver documented in the script header and in docs/process/boundary-waivers.md; new cross-layer checks should move runtime-neutral metadata into shared/ instead of expanding the waiver set.


TypeScript Target Constraints

Both the root tsconfig and worker tsconfig target ES2022, but the worker config pins lib: ["ES2021"]. Shared modules in shared/lib/ compile under both configs and may use ES2022 syntax (nullish assignment ??=, logical assignment ||=, top-level await, etc.) — but not the ES2022 library surface (Array.at(), Object.hasOwn(), …), which does not typecheck under the worker config — and must remain runtime-neutral — no DOM APIs, no Node-only APIs, no Cloudflare-only APIs.


Architectural Decision Records

Load-bearing, deliberately-locked decisions and why they exist, so the rationale survives independent of the people/agents who set them. These are constraints, not aspirations — change them only with a deliberate, documented follow-up. The CLAUDE.md / agent instructions hard-rules block is the enforceable summary; this section is the rationale.

Active Records

  • ADR-1 — Root tsconfig excludes worker/. worker/tsconfig.json sets "types": ["@cloudflare/workers-types", "node"], whose global types (and D1Database et al.) conflict with the frontend's DOM lib. Compiling both under one config produces ambient-type clashes, so the root config (tsconfig.json exclude) drops worker/ and the worker compiles under its own config. Consequence: code shared by both runtimes must be runtime-neutral and live in shared/lib/, never reach into worker- or DOM-only APIs.
  • ADR-2 — @shared/* alias boundary. Frontend and worker both import shared runtime/types through the @shared/* / @shared/data/* path alias (tsconfig.json paths), not relative cross-boundary paths. This keeps the runtime-neutral boundary explicit and greppable; the ESLint import-boundary rules enforce it (see Worker import boundary waiver above for the single named exception).
  • ADR-3 — Methodology versioning is numeric-decimal, not semver minor. Methodology versions increase as decimals: after v5.9 the next step is v5.91 or v6.0, never v5.10. This avoids the 5.9 < 5.10 ambiguity that bit earlier integer-segment version comparisons; compare versions numerically. A methodology change updates four places, and this record owns that enumeration: the owning domain's runtime version source (the per-domain constant in shared/lib/methodology-versions/constants.ts, surfaced through shared/lib/methodology-versions/registry.ts; for the Safety Score domain that constant reads shared/lib/methodology-versions/current-version.json, which belongs to that domain only), the structured changelog entry under shared/data/methodology-changelogs/, the owning methodology document, and the relevant /methodology section when public explanation changes. Skipping the runtime version source ships a changelog entry for a version the runtime never adopts.
  • ADR-4 — One Worker for OG + API + cron. A single Cloudflare Worker serves the public/site/ops API surfaces, dynamic OG image generation, and all scheduled (cron) runtime work rather than splitting into per-concern Workers. This keeps one deploy/rollback unit and one D1 binding set. Cloudflare limits each invocation to six simultaneous outbound requests waiting for response headers; Pharos conservatively budgets the whole trigger at six and requires response-body cleanup before later phases to bound resources and preserve deterministic sequencing (see worker-infrastructure.md).
  • ADR-6 — Squashed D1 baseline. worker/migrations/0000_baseline.sql consolidates migrations 0001–0227 into one fresh-DB schema script; existing databases never run the baseline and continue from their last-applied migration, while fresh databases apply the baseline then 0228 onward. The legacy-duplicate allowlist is frozen and must not expand. Cadence/procedure for the next squash lives in docs/process/d1-baseline-squash-plan.md; lineage is in worker/migrations/MANIFEST.md.
  • ADR-7 — Single-source route and cron metadata. Static route metadata is declared once in shared/lib/api-endpoints/ and cron schedules once in shared/lib/cron-jobs.ts (bound to runner keys via shared/lib/scheduled-runner-registry.ts); worker route/dispatch and CI sync checks consume those single sources. This keeps wrangler.toml, shared metadata, and dispatch in lockstep and makes drift a compile/CI failure rather than a silent default. See Route Definition Model above.
  • ADR-8 — DefiLlama list supply is read as-is. Use getCirculatingRaw() from shared/lib/supply.ts; DefiLlama list-endpoint circulating values are already USD-denominated, so they must never be multiplied by price or replaced with a manual/on-chain/CMC/DEX override. Separate supplemental supply admission paths for assets missing from, or broken in, the list are allowed only when source-owned, documented, fail closed, and conserve supply without double-counting. This keeps the primary DefiLlama basis stable while letting explicit fallback lanes repair covered gaps.
  • ADR-9 — Per-cache availability ratio overrides. Public cache availability normally tolerates the global >8× degraded / >12× stale age bands (STATUS_CACHE_RATIO_THRESHOLDS), but a cache whose slow stale window under-reports real user impact can tighten those bands via STATUS_CACHE_RATIO_OVERRIDES in shared/lib/status-thresholds.ts. yield-data overrides to >2×/>4× against its post-V9 producer so two missed publishes make the public status degrade (ruling R3, 2026-07-19) — previously the global bands let multi-hour-stale rankings still read healthy while the admin endpoint-budget lane already flagged the lane at 1×. Overrides are resolved through getCacheRatioThresholds/getCacheHealthyMaxRatio and threaded (as an optional cache key) into getCacheFreshnessStatus/getCacheImpactStatus and the worker buildCacheStatuses healthy/statusFloor path; the per-record freshness layer (FRESHNESS_RATIOS) and all non-overridden caches are unaffected. See docs/status-dashboard.md.
  • ADR-10 — Cron growth headroom is topology-reviewed, not a single expression count. The former 20-expression soft cap predated the fully source-owned schedule registry, slot fencing, per-trigger connection-budget validation, and compact producer-history read model. The audited topology distinguishes deployed Cloudflare trigger expressions from logical scheduled work: CRON_GROWTH_HEADROOM_POLICY in shared/lib/cron-jobs.ts caps the reviewed physical topology, fetch-capable scheduled entries, and headroom-full (5/6) trigger slots; read the current ceilings from that constant rather than from prose, and the rationale for each raise from the ADR that made it. Crossing any gate requires another ADR and consolidation/rebalance plan. These caps are internal operability guards, not permission to add unaudited triggers.
  • ADR-19 — Domain-composed Safety Score. Safety Score V9 is the single judgment of asset risk, composed from domain producers rather than competing with them. Three rules are load-bearing. (i) One engine per sub-dimension. A risk sub-dimension has exactly one scoring implementation; a second surface may present a different view of that engine's output but must not carry its own formula. The exit route-scoring engine (safety 9.07 wave) and the mint grader (safety 9.1) were each consolidated after the two copies had silently diverged — the mint pair shared five posture-enum names with different values and had opposite incident semantics, and the exit pair mirrored every scoring constant with no test pinning them equal. (ii) Anti-double-counting. A signal enters the composition exactly once, at the layer that owns it. This is why the retired Mint Authority engine's route-family pricing was not merged into the control pillar at 9.1: capSemantics already prices the same risk, and pricing it twice would double-count. (iii) Downstream consumers re-bin, never re-derive. DDR is the model: it reads published outputs and re-bins them. Any consumer that recomputes a published score is a divergence waiting to happen, which is why DDR's last re-derivation (the mint band) moved to the published posture band at 9.1. Consequences: curated fields that duplicate a derived judgment become validated annotations with a curation queue rather than score inputs (mintAuthority.authorityPosture); every scoring constant lives in the V9 policy asset so the determinism contract is one digest; and any commit touching a manifest-pinned scoring source regenerates the V9 evaluation-build manifest in the same commit. See report-cards.md and mint-authority-scoring.md.
  • ADR-20 — Cron CPU-class rebalance and physical-trigger freeze. Cloudflare's Workers limits give Paid-plan Cron Triggers a 30-second CPU class for expressions with intervals below one hour and a 15-minute class for hourly-or-longer intervals; an invocation that consistently exceeds its CPU limit is terminated. Production D1 measurement on 2026-08-21, using cron_runs synthetic platform-abandoned rows written by worker/src/lib/scheduled-slot-reconciliation.ts, verified abandonment in the three single-expression sub-hourly lanes: statusSelfCheckOffset (9,24,39,54 * * * *) abandoned 28/96 slots (29.2%), v9SupplyAttributionOffset (8,23,38,53 * * * *) abandoned 28/96 (29.2%), and quarterHourly (*/15 * * * *) abandoned 27/96 (28.1%). In 140/146 rows, slotWorkerVersion == reconciledByWorkerVersion, which rules out deploy eviction as the main explanation. The existing paired-hourly DEX lanes had near-zero loss: halfHourlyChartsOffset (16 + 46) was 0/48 (0.0%) and halfHourlyOffset (10 + 40) was 1/48 (2.1%). Loss landed on serial-chain tails: compute-depeg-resolver lost 49/192 runs (25.5%), including six consecutive slots with no depeg resolution for 90 minutes; cron-staleness-watchdog and data-invariant-canary were starved for 90 and 75 minutes. The reviewed fix adds hourly triggerSchedules aliases in shared/lib/cron-jobs.ts and the matching single-minute expressions in worker/wrangler.toml while leaving logical schedules and cadence definitions unchanged. The resulting physical expressions were the reviewed topology at the time of this record (ADR-21 later raised it), not general-purpose spare capacity: splitting an existing comma expression into hourly aliases is a topology rebalance of existing logical work, not new scheduled work, and does not add logical slots, fetch surface, or connection pressure. Further physical-trigger growth is frozen pending a consolidation or Cloudflare Queues/Workflows decision. The fiveMinuteTelegramAlerts lane is explicitly deferred as the next review target: its measured abandonment was 44/288 (15.3%), and the same alias approach would cost +11 expressions. Child-level attribution over seven days shows the same monotonic chain-position signature (dispatch-telegram-alerts 37, telegram-personalized-recap-planner 40, telegram-degradation-watchdog 44, telegram-disambiguation-cleanup 51, telegram-pulse-snapshot 68), but dispatch-telegram-alerts drains a durable pending queue, so an abandoned slot costs delivery latency rather than lost alerts. That durability, not a low abandonment rate, is what makes deferring this lane acceptable; a one-shot writer at the same rate would not be.
  • ADR-21 — V9 publication CPU-class rebalance. ADR-20 measured platform-abandoned rows on 2026-08-21 at 28/96 (29.2%) for both statusSelfCheckOffset and v9SupplyAttributionOffset, and 27/96 (28.1%) for quarterHourly, versus 0/48 for halfHourlyChartsOffset and 1/48 for halfHourlyOffset on paired-hourly aliases. v9PublicationOffset had the same proven-bad single-expression sub-hourly shape (22,52 * * * *) but was neither included in that rebalance nor named as its deferred follow-up; it was missed. This lane cannot use ADR-20's deferral rationale: that record permits deferral for fiveMinuteTelegramAlerts because it drains a durable pending queue, and states outright that "a one-shot writer at the same rate would not be" acceptable. compute-safety-score-v9 is exactly such a one-shot publication writer, so platform termination can lose a publication attempt rather than merely delay durable queued work. The reviewed correction deploys 22 * * * * and 52 * * * * as physical aliases while preserving the logical 22,52 * * * * schedule, slot key, half-hour cadence, publication window, and zero-connection declaration. Its cost is +1 physical expression, moving the reviewed topology from 34 to 35, versus the +11 expressions that justified deferring the Telegram lane. This is a topology rebalance of existing logical work: it adds no scheduled work, fetch surface, or connection pressure. Further growth beyond 35 remains frozen pending another ADR and consolidation or execution-substrate review.
  • ADR-22 — DDR run-scoped heap separation and mint/burn CPU-class rebalance. The hourly-alias correction removed the 30-second CPU-class fault from v9SupplyAttributionOffset, but seven-day production evidence on 2026-08-27 still showed 82/671 abandoned slots. Eighty were same-version in-place deaths, and the post-rebalance child split was decisive: 43 of 45 losses occurred after attribution when compute-depeg-resolver loaded its review graph in the same isolate, versus two attribution losses. Commit 74ca9d2b57 recorded Cloudflare's confirmed exceededMemory outcome for this chain; both dynamic graphs include the large generated stablecoin catalog, so serial ordering and cooperative timeouts cannot provide a memory boundary. DDR therefore moves unchanged to the independent logical depegResolverOffset lane at 13,28,43,58, deployed as four hourly aliases for the hourly CPU class. This prevents the two large run-scoped graphs from executing in one invocation; the same Worker service may reuse a warm isolate, so observed production runs remain the acceptance criterion. It remains D1-only (0/6), keeps its 900-second freshness contract, and retains the core/version and V9 memory-lane admission fence. The same review pairs halfHourlyMintBurnCritical as hourly 4 and 34 aliases: 17/332 healthy runs exceeded 30 seconds and three same-version 2026-08-21 abandonments ended at 42–133 seconds, making the single sub-hourly expression a proven CPU-class hazard even though the rolling abandonment ratio was below the topology watchdog threshold. These changes add one logical zero-fetch plan and five net physical expressions, moving the current reviewed topology from 34 to 39 without increasing the 32 fetch-capable entries or the two 5/6 slots. DDR scans roughly 8,800 incident rows, so the Queue/Workflow fan-out review threshold was considered; a separate D1-only snapshot-compiler invocation with sub-30-second p95 is materially simpler than introducing queue orchestration, while removing run-scoped overlap without claiming service-level isolate separation. Further physical-trigger growth beyond 39 remains frozen pending another ADR and consolidation or execution-substrate review.
  • ADR-23 — Extended mint/burn CPU-class rebalance. ADR-22 corrected only halfHourlyMintBurnCritical; halfHourlyMintBurnExtended remained on the combined 18,48 * * * * expression and therefore in Cloudflare's 30-second CPU class. Production acceptance on 2026-09-01 proved the omission after commit a988acf22 removed the 15-MiB rich stablecoin registry from the extended lane: Worker version 7c171ad5-6421-49bd-8a5e-78408420bf87 completed the 14:18 UTC attempt in 61.3 seconds but its 14:48 and 15:18 attempts stopped heartbeating after 39 seconds at scan-config and were reconciled as same-version platform-abandoned, with interruptedByWorkerVersionChange=false. The code-level memory reduction was necessary but could not override the platform class. The reviewed correction preserves the logical 18,48 * * * * cadence and slot identity while deploying hourly 18 * * * * and 48 * * * * aliases. This adds one physical expression, moving the reviewed topology from 39 to 40, but adds no logical work, fetch-capable entry, or connection pressure: the lane remains isolated at 1/6, with 122 configured units and normal runtime far below the 10-minute and 1,000-unit Queue/Workflow review thresholds. A topology alias is therefore materially simpler than changing ingestion semantics or adding orchestration. Further physical-trigger growth beyond 40 remains frozen pending another ADR and consolidation or execution-substrate review.
  • ADR-24 — Five-minute Telegram lane runs on the narrow Worker runtime registry. ADR-20 deferred the fiveMinuteTelegramAlerts CPU-class rebalance on the grounds that dispatch-telegram-alerts "drains a durable pending queue, so an abandoned slot costs delivery latency rather than lost alerts". Production evidence on 2026-09-02 falsified both halves. Cloudflare invocation analytics (the workersInvocationsAdaptive dataset, 2026-08-26 to 2026-09-02) recorded 196 exceededMemory outcomes and zero exceededCpu outcomes for the Worker; the D1 platform-abandoned rows for the lane land on the :27/:57 runs that carry safety fan-out after the :22/:52 V9 publication (20 of 96 such runs abandoned in 48 hours, versus 2 of 480 eventless runs), always at fanout-built after 17-39 seconds, i.e. during the pending drain. Every kill reconciled the in-flight sending rows to execution_unknown with pending_effect_owner_lost: 317 such rows accumulated since 2026-07-29. That state means the Bot API effect is unconfirmed and, by contract, is never retried, so each abandonment converts due deliveries into permanent ambiguity rather than latency. The cause was the lane's dynamic-import graph: eleven Telegram modules imported shared/lib/stablecoins/registry.ts, which inlines the 15.8 MB coins.generated.json, giving the five-minute bundle 19.0 MB of inputs inside the 128 MB isolate (the same hazard commit a988acf22 removed from the extended mint/burn lane). The lane reads only id, symbol, name, status, and flags.pegCurrency, so the reviewed fix adds name and pegCurrency to the coins.worker-runtime.generated.json projection, adds pre-launch and active-meta indexes to worker-runtime-registry.ts, moves all eleven modules onto it (bundle inputs 19.0 MB to 3.2 MB), and extends check:mint-burn-runtime-imports to bundle five-minute-telegram.ts so the full registry cannot re-enter. No trigger, schedule, slot, or connection budget changes; the physical topology stays at 40. The first production observation on Worker 07c1ac69 proved the registry removal necessary but not sufficient: the 20:27 and 20:57 UTC safety fan-out dispatches now completed, but the isolate still died of exceededMemory immediately afterwards and abandoned the watchdog, cleanup, and pulse sidecars. The second heap source was worker/src/cron/telegram-alert-context.ts, which built the alert Context: line by calling loadActiveSafetyScoreSource — a full ~8 MB V9 publication gunzip and parse on every event-carrying dispatch — bypassing the thin alert-safety-v9-source envelope that alert-safety-source-cache.ts introduced after the 2026-08-19 OOM for exactly this lane. Worker 647e5b01 reads grade, score, and publication identity from that envelope instead. Acceptance is therefore two-part: the first week of production :27/:57 safety-event dispatches on 647e5b01 or later completing with their sidecars, without platform-abandoned rows, exceededMemory outcomes, or new pending_effect_owner_lost rows. If abandonment persists, the next candidates are bounding the drainPendingQueue claim and materialization window, then the hourly-alias rebalance ADR-20 costed at +11 expressions.

Superseded Records

  • ADR-5 — Historical penalty-only score blends (superseded by ADR-19). Safety Score V8 applied reviewed Mint Authority, CDP oracle, and bridge-route scores to its Decentralization dimension through penalty-only blends. Canonical V9 retires that numeric blend and compiles the underlying reviewed evidence into typed Backing, Exit, and Economic Control facts, gaps, and caps. The historical rule remains documented so archived V8 results are reproducible; it is not an active V9 scoring path. ADR-19 states the positive rule that replaced it. See report-cards.md and classification.md.

Stablecoin lifecycle phases

Every entry in TRACKED_STABLECOINS is in one of five lifecycle phases. The phase controls write-side collection, live aggregates, and public presentation. Phase transitions are a catalog policy change, not a scoring-algorithm change, so per-domain methodology versions are unaffected unless the scoring formula also changes.

Phasestatus fieldNew data collected?Score recomputation?Public treatment
Active"active" (or omitted)YesYesLive tables, analytics, aggregates, alerts, and detail page
Pre-launch"pre-launch"NoNo/upcoming/ and pre-launch detail variant
Quarantined"quarantined" plus listingStatusReviewNoNoStatic read-only detail record with reason and review date
Delisted"delisted" plus sourced listingStatusReviewNoNoStatic historical detail record
Frozen"frozen" plus frozenAt and obituaryNoNo/cemetery/ and preserved archive detail page

The main registry universes from shared/lib/stablecoins/registry.ts are:

  • TRACKED_STABLECOINS — the complete catalog across all five phases. Use for canonical identity, schema validation, static detail params, sitemap entries, and known provider IDs in discovery.
  • ACTIVE_STABLECOINS — active or omitted status only. Every write-side cron, live aggregator, PSI/DEWS/Bank-Run-Gauge input, and Telegram alert target must use this universe.
  • PRE_LAUNCH_STABLECOINS, QUARANTINED_STABLECOINS, DELISTED_STABLECOINS, and FROZEN_STABLECOINS — explicit lifecycle partitions that drive their respective static or archive surfaces.
  • READABLE_STABLECOINS — all post-launch records: active, quarantined, delisted, and frozen. Use only for historical/read-only navigation and identity resolution, never live collection or cache publication.

Listing scope, classes, quarantine, and delisting are defined in Stablecoin Listing Policy. The freeze procedure is documented in Freezing a Tracked Stablecoin.

Funding page

The /funding route is a static page backed by the hand-maintained cost and donation records under shared/data/funding/; it has no cron, D1, or API surface. Use the project funding-update skill for the approval-gated research workflow and Funding Page for the data contract.