Architecture Decision Records

ADR-001 through ADR-015 for AXIS: registry, AXIS≠engine, Solid+Vite, dual engines, configSchema, URL load, storage, CF project id, proxies, DO fan-out, migration, CORS, results export, transport preference + telemetry, on-chain data plane.

This page

Architecture Decision Records

Formal ADRs for AXIS (product + frontend/). Status values: Accepted unless noted. Dates are conceptual product epochs, not git archaeology.

Index

IDTitle
ADR-001Pluggable unified registry
ADR-002AXIS ≠ engine
ADR-003Solid + Vite product UI
ADR-004Dual engines (server + Pyodide)
ADR-005Declarative configSchema
ADR-006URL-loadable plugins
ADR-007Storage as a plugin
ADR-008CF ids: Pages axis, Worker worker-axis
ADR-009Worker proxies first
ADR-010Durable Object stream fan-out
ADR-011State namespace migration
ADR-012CORS as deploy concern
ADR-013Results export in AXIS
ADR-014Transport preference + connection telemetry
ADR-015On-chain data plane
ADR-016Provider-locked market data; CCXT on the server

ADR-001: Pluggable unified registry

Context

Early AXIS registered sources/streams/engines in separate ad hoc maps. Operators and authors needed one mental model and one install path.

Decision

Adopt a unified PluginRegistry (frontend/src/plugins/registry.ts) with kinds:

source | stream | engine | storage | component(reserved)

Every plugin shares PluginBase (id, name, kind, description, version, builtIn, configSchema, capabilities, lifecycle hooks). Registration is ordered; listeners observe register/unregister.

Consequences

  • Active selection lives in the Solid store, not the registry.
  • Built-ins protected from casual unregister.
  • Catalogs (sources/catalog.ts, …) become registration facades.
  • Contract docs under Plugins.

ADR-002: AXIS ≠ engine

Context

Closed chart hosts couple rendering and language runtime. That blocks offline mode, alternate evaluators, and headless reuse of PYNE.

Decision

AXIS never embeds a closed interpreter. All evaluation goes through EnginePlugin.run. UI modules call getActiveEngine() / runAndApply, not Python bindings directly (except inside the pyodide engine module).

Consequences

  • Language fidelity owned by PYNE.
  • AXIS docs do not duplicate grammar theory.
  • New engines (tiny demo, remote Worker, future WASM) are additive plugins.

ADR-003: Solid + Vite product UI

Context

Legacy vanilla JS shell (main.js, style.css) mixed DOM wiring with business logic and aged TV-blue tokens.

Decision

Product path is SolidJS + Vite + Tailwind 4, entry src/index.tsx / app.tsx. Imperative chart library (lightweight-charts) is isolated in ChartHost / PaneManager so Solid reconciliation does not thrash canvas DOM.

Consequences

  • Legacy files retained for smoke/static tests only (LEGACY.md).
  • Icons via lucide-solid.
  • Deploy artifact is Vite dist/, not repo-root static tree.

ADR-004: Dual engines (server + Pyodide)

Context

Researchers need server fidelity and offline demos. A single transport cannot satisfy both without compromise.

Decision

Ship two built-in engines:

IdRole
serverPOST {endpoint}/run?mode= with { script, data: bars }
pyodideBrowser Python + vendored pynescript / antlr wheels

Both implement the same EnginePlugin contract and return RunResult.

Consequences

  • Endpoint field only for server-like engines.
  • Pyodide requires correct static asset deployment (ZIP integrity checks).
  • Timeouts unified at runner layer.

ADR-005: Declarative configSchema

Context

Per-plugin settings UIs do not scale; custom React/Solid forms per plugin forks the manager.

Decision

Plugins declare optional configSchema: map of field name → { type, default, label, options, min, max, ... }. Settings and library panels resolve defaults + pluginsConfig overrides via shared resolve helpers.

Consequences

  • New plugins get settings UI without shell changes (within field types).
  • Types: string | number | boolean | select initially.
  • Invalid user values remain a plugin responsibility at runtime.

ADR-006: URL-loadable plugins

Context

Third-party sources/engines should not require rebuild of the PWA for experiments.

Decision

Support loadPluginFromUrl: fetch ES module, validate shape, register, persist install metadata for restore on boot. Example plugins published under public/plugins/.

Consequences

  • Browser origin trust model: URL plugins are code execution.
  • Security tests constrain schemes and storage-via-URL footguns.
  • Offline restore needs prior successful fetch/cache.

ADR-007: Storage as a plugin

Context

Script library began as localStorage-only; cloud and git were bolted differently.

Decision

Storage is a first-class plugin kind with list/read/write/remove (+ optional draft/sync/status). Built-ins: local (IDB), cloud (Worker /api/scripts), git (GitHub/GitLab Contents API). Active storage in activePlugins.storage.

Consequences

  • Manager Script Library is backend-agnostic.
  • Git commits only on explicit save; drafts local.
  • Cloud uses Bearer Pro keys and optional optimistic concurrency.

ADR-008: Frozen CF project id

Context

Renaming Cloudflare Pages/Worker projects severs KV/D1/R2 bindings, custom domains, and CI secrets. Product brand evolved to AXIS. The original freeze used a single id pynescript-axis for both Pages and the Worker.

Decision

Pages project is axis. Canonical URL https://axis.hoox.sh (custom domain). Preview hashes live on *.axis.pages.dev. The previous Pages id pynescript-axis remains a CORS legacy alias for old preview hosts.

Worker script is worker-axis. Canonical URL https://worker.axis.hoox.sh (custom domain). Health JSON service is worker-axis. The pre-rename workers.dev host (pynescript-axis.<account>.workers.dev) is treated as a legacy alias in client host matching.

Brand in UI/manifest/docs is AXIS.

Consequences

  • wrangler.toml name is worker-axis; Pages --project-name is axis.
  • Canonical hosts: Pages https://axis.hoox.sh, Worker https://worker.axis.hoox.sh.
  • Docs call out the split explicitly (this ADR).
  • npm package name (axis-worker) may lag brand.

ADR-009: Worker proxies first

Context

In-Worker Pyodide is attractive but heavy; production needed a path immediately.

Decision

Worker /api/run proxies to an external Python backend first (EXTERNAL_BACKEND). In-Worker runtime remains scaffolded/gated. Keys, usage metering, scripts API, and stream DO can ship independently of full edge evaluation.

Consequences

  • AXIS server engine talks to Worker URL transparently.
  • Operational dependency on Flask (or equivalent) until edge runtime matures.
  • Clear layering: Worker is data plane + auth, not necessarily the interpreter.

ADR-010: Durable Object stream fan-out

Context

N browser clients each opening venue WebSockets waste connections and risk rate limits.

Decision

SessionDO Durable Object: one upstream WS per session key; fan-out to N client sockets via /api/stream. Hibernation-friendly design on CF.

Consequences

  • Stream plugins may target DO URLs (example plugin provided).
  • Session/symbol/interval query params are part of the contract.
  • Not a historical source—live only.

ADR-011: State namespace migration

Context

Hard-renaming storage namespaces would brick user layouts and libraries if keys changed hard.

Decision

Canonical key pynescript.axis.v1. On read, fall back to pynescript.axis.v2 (and parallel library/draft keys), then write forward. Do not keep dual-write forever.

Consequences

  • Seamless upgrade for existing browsers.
  • Persistence strips bars/lastRun/logs.
  • Tests cover migration (tests/state.test.ts patterns).

ADR-012: CORS as deploy concern

Context

Browser AXIS on origin A calling Pro API on origin B fails without CORS. Hardcoding origins in AXIS is wrong.

Decision

CORS is a backend/deploy configuration (ALLOWED_ORIGINS on Flask; Worker headers as deployed). AXIS documents required origins; ops sets explicit lists in production (avoid perpetual * outside demos).

Consequences

  • Localhost regex for dev.
  • Troubleshooting centers on preflight, not engine code.
  • Static file server does not replace API CORS.

ADR-013: Results export in AXIS

Context

Researchers need artifacts (trades CSV, run JSON) without a separate analytics service.

Decision

Results panel owns export: download JSON of lastRun, CSV of closed trades, clipboard helpers. Strategy pairing and stats run client-side from engine events (results/strategy.ts, results/events.ts).

Consequences

  • Export fidelity bounded by engine event quality.
  • No server-side report store required for MVP.
  • Viewer metrics ≠ broker statements (documented for operators).

ADR-014: Transport preference + connection telemetry

Context

Operators need to see how data and calculation move (WS vs REST vs local vs brokered), whether the live socket is actually open, and engine run latency. Naively “prefer WebSocket everywhere” is wrong: venues expose history over REST and live klines over WSS. Server engines batch-run over HTTP; Pyodide is local WASM.

Decision

  1. Transport policy
    • History → REST (or local mock/CSV).
    • Live → WebSocket first, paired per source via defaultStreamForSource (binance-restbinance-ws, …).
    • Engine → prefer WS /ws/run on the Pro API when available (flask-sock); fall back to HTTP POST /run. Pyodide remains local.
    • Brokered → CF Durable Object / needsProxy streams surface as transport class broker.
  2. Honest stream stateconnecting until onStatus({ state: 'open' }); reconnect uses reconnecting / degraded telemetry, not false green.
  3. Venue streams use reconnect with exponential backoff (streams/reconnect-ws.ts).
  4. Connection HUD — StatusBar second row (ConnectionHud) reads ephemeral store.telemetry (SRC/STR/ENG/STO chips, tick pulse, engine latency). Persist only telemetry.hud prefs + live.preferAfterLoad / live.rerunOn.
  5. Live re-run modesevery-tick (default) or bar-close (venue Bar.closed or bar time advance).
  6. Chart stability — full setDataToChart only on history load (chartDataGen); live uses appendBar; overlay/script drawings update in place to avoid hide/show flash.

Consequences

  • UI never claims WS for historical load.
  • Auto-live after Load is on by default (preferAfterLoad). Turn off in Settings → Live stream.
  • Plugin capabilities may declare transport; otherwise HUD classifies from id/capabilities.
  • See Streams, UI shell, Overview.

ADR-015: On-chain data plane

Context

AXIS began as an OHLCV-first chart host: SourcePlugin / StreamPlugin deliver Bar[] (open, high, low, close, volume) for CEX and similar venues, then engines run Pine against that series. Operators also need non-price market structure:

NeedWhy Bar alone fails
Protocol TVL (USD)Single scalar liquidity total, not OHLC
DEX pool candlesSame shape as bars, but different venue semantics, address/network keys, and page caps
Spike / unlock-style eventsDiscrete time markers with severity, not candles
Honest provenanceProvider, snapshot cadence, finality — not implied by “the chart symbol”

Forcing these into the primary Bar pipeline would corrupt scale semantics (TVL vs price), invent fake OHLC for scalar metrics, and couple wallet/signing UX to read-only analytics.

Decision

Ship a parallel on-chain data plane orthogonal to the OHLCV source/stream path:

  1. Plugin kind dataset on the unified registry (DatasetPlugin in src/plugins/types.ts):
    • fetchDataset(opts) → OnchainDataset
    • optional searchInstruments
    • payload kinds: ohlcv | scalar_series | multi_series | events | table
    • required provenance + finality (pending | safe | finalized | unknown)
  2. Parallel plane modules under src/onchain/ (types, cache, keys, adapters, catalog, manager, events, alerts bridge) — not a new Bar field and not Pine output.
  3. Worker allowlist proxy (worker/src/onchain.ts, routes under /api/onchain/…):
    • rewrite only known DefiLlama / GeckoTerminal paths
    • validate network ids and pool addresses
    • short-TTL isolate cache (X-Axis-Onchain-Cache)
    • client default bases from store.endpoint (src/onchain/proxy.ts)
  4. Chart UX: attach as host-side overlays (left scale for scalars; separate series keys onchain_*); main market OHLCV stays on the right scale. Not wallet, not Pine plots.
Diagram

Rendering…

Alternatives considered

AlternativeRejected because
Force into Bar / active sourceTVL is not OHLC; faking open=high=low=close lies to engines and the price scale. Multi-protocol attach becomes multi-symbol load thrash.
Piggyback Pine request.security onlyNeeds engine + script for every host overlay; no attach UI, no provider search, no finality UI.
Browser → provider directPublic DefiLlama / GeckoTerminal hosts often lack CORS for arbitrary AXIS origins (ADR-012).
Open reverse proxy (any URL)SSRF risk; Worker must allowlist path shape, not pass-through arbitrary upstream.
Wallet-connected RPC as the planeDifferent product (signing, accounts, private RPC). On-chain here means public analytics APIs, not MetaMask.

Consequences

  • CORS / deploy — browsers should use {endpoint}/api/onchain/llama|gecko; local Worker on :8787 or deployed edge. Direct baseUrl overrides are for Node tests / trusted environments only.
  • Finality honesty — DefiLlama TVL is typically a daily snapshot, not block-final chain state; Gecko OHLCV is pool sampling, not CEX marks. UI shows provenance lines; synthetic flags fabricated OHLC.
  • Not a wallet — no signing, no injected providers, no private keys for this plane.
  • Not Pine — overlays are host datasets; they do not replace indicator() / strategy() plots from the engine.
  • Engines unchanged — active OHLCV source still feeds EnginePlugin.run; on-chain series do not silently become script close.
  • Cache — client IDB dataset cache (instrumentCacheKey) plus Worker short-TTL memory; operators may see stale TVL briefly after large DeFi moves.
  • Security — Worker rejects path traversal, invalid slugs/addresses, non-GET, and non-allowlisted Gecko paths.

Phases shipped

PhaseCapabilityPrimary surfaces
1 — TVLDefiLlama protocol TVL scalar history + searchdefillama-tvl dataset, src/onchain/defillama.ts, llama proxy
2 — DEXGeckoTerminal pool OHLCV + pool search; optional geckoterminal-ohlcv source for DSM multi-page backfillgeckoterminal dataset, src/onchain/geckoterminal.ts, gecko proxy
3 — EventsDerived day-over-day TVL spike/drop events (tvl_spike / tvl_drop)src/onchain/events.ts → chart markers
4 — AlertsBridge events into alerts engine (onchain_tvl_spike / onchain_event)src/onchain/alerts-bridge.ts, src/alerts/engine.ts

Pro-only DefiLlama surfaces (raises, unlocks via pro-api.llama.fi) remain out of the free Worker allowlist.

Code map

PathRole
src/onchain/types.tsOnchainDataset, instruments, finality
src/onchain/catalog.tsBuilt-in dataset registration
src/onchain/manager.tsAttach / hide / detach store wiring
src/onchain/proxy.tsResolve Worker llama/gecko bases
worker/src/onchain.tsAllowlisted proxy + health
src/plugins/types.tsDatasetPlugin contract

Related docs


ADR-016: Provider-locked market data

Context

AXIS composes source × stream × engine, but OHLCV aggregation is not composable. Mixing Binance history with OKX live (or Kraken watchlist quotes on a Binance chart) silently poisons indicators, compare, watchlist, and cache. Prior to this ADR, source and stream were independently selectable, venue detection used plugin-id substrings, and the Binance synthetic fallback made network errors look like real data.

Key problems:

IssueRisk
Source and stream independently selectable in TopbarUser loads Binance REST + OKX WS — mixed candles
DSM sourceId seeded once from store.sourceBackfill targets a different venue than the chart
Watchlist Kraken → Binance tickersMixed-provider quotes
Coinbase WS ticker → synthetic barsNot exchange candles; OHLCV ≠ REST history
Binance fallback: true synthesizes random walkChart looks "live" with fake prices

Decision

Introduce ProviderSession — a first-class locked market-data identity that all aggregators inherit:

  1. ProviderSession (src/data/provider.ts): { id, sourceId, streamId, venue, market, authMode, credentialId, gateway }. Persisted without secrets. Every aggregator reads getActiveProvider() instead of raw URLs or plugin-id substrings.

  2. Auto-pairing: Source changes always re-pair the stream via defaultStreamForSource. Explicit mismatch is allowed but shows a HUD ⚠ pair · Fix chip.

  3. Plugin capabilities: venue, market, klineStream on PluginCapabilities. Venues with klineStream: true emit exchange candles (not ticker buckets).

  4. Coinbase WS: switched from ticker buckets to Advanced Trade venue candles, folded into chart interval via foldVenueCandle.

  5. Binance fallback: defaults off (fallback: false). Network errors must not look like real data.

  6. Watchlist lock: no silent Binance fallback for Kraken, Gecko, or unknown venues. Each shows a clear "no live mux" message.

  7. Credential vault (src/data/credentials.ts): in-memory session-only vault for API key/secret/passphrase. Secrets never reach persist(), pluginsConfig, error-share, or CHANGELOG dumps. redactSecrets and stripSecretKeys enforce this.

  8. Venue HMAC signers (src/data/venues/): thin Binance / OKX / Bybit / Coinbase / Kraken / MEXC signers. No CCXT in the browser.

  9. Signed Binance klines: vault key → HMAC direct fetch → Worker /api/market/binance/signed/klines (request-scoped X-Exchange-Key / X-Exchange-Secret). 401/403 stays thrown — bad keys must not silently mix public klines.

  10. Cache key: includes providerId|market|authMode|symbol|interval. Authenticated and public candles do not share cache slots.

Ownership

┌─────────────────────────────────────────────────────────────────┐
│ AXIS (composition host)                                         │
│  ProviderSession (venue + market + auth mode + credential ref)  │
│  SourcePlugin  ── REST history / pagination / DSM / compare     │
│  StreamPlugin  ── venue kline WS (not ticker-synthesized bars)  │
│  Credential vault (browser session / optional persist / Tauri)  │
│  Datafeed Gateway client → pyne | worker | sidecar              │
└───────────────┬─────────────────────────┬───────────────────────┘
                │                         │
                ▼                         ▼
┌───────────────────────────┐   ┌─────────────────────────────────┐
│ PYNE [data]/[datafeed]    │   │ AXIS Worker                     │
│ CCXT + CCXT Pro           │   │ Thin HMAC REST proxy (no CCXT)  │
│ CLI, request.*, gateway   │   │ DO WS fan-out (public + auth    │
│ Secrets: env / key store  │   │ listenKey) — allowlisted paths  │
└───────────────────────────┘   └─────────────────────────────────┘

Alternatives considered

AlternativeRejected because
Bundle CCXT in browser~1 MB+ bundle, Node APIs, isolate CPU, CORS issues
CCXT in Cloudflare WorkerBundle size, 128 MB memory, shared rate-limit IP, not designed for 100-exchange routing
Keep venues as raw URL substringsSilent venue mismatches poison aggregation correctness
Pluggable venue resolverOver-engineered; first-party venues are a fixed set of 5

Consequences

  • Correctness — aggregation (indicators, compare, watchlist, cache, DSM) inherits a locked venue. Mixed-provider bugs are structurally prevented.
  • Auth path — public remains the default. Auth is a capability upgrade on the same provider, not a second source id.
  • Secret safety — vault is RAM-only in v1. Tauri keychain / AES-GCM durable wrap is Phase 1.5. pluginsConfig and error-share are stripped of secret keys.
  • CCXT stays in PYNE — server-side only. AXIS calls the gateway with session handles, never raw secrets in /run.
  • Long-tail exchanges — AXIS ccxt-rest / ccxt-ws plugins that talk to a PYNE or Bun sidecar gateway. First-party venues stay thin native adapters.

Key files

PathRole
src/data/provider.tsProviderSession type, buildProviderSession, defaultStreamForSource
src/data/credentials.tsIn-memory vault, putCredential, redactSecrets
src/data/signed-fetch.tsVault → HMAC → direct or Worker proxy
src/data/venues/Per-venue HMAC signers (binance, okx, bybit, coinbase, kraken)
src/plugins/active.tsgetActiveProvider()
src/plugins/types.tsPluginCapabilities.venue, .market, .klineStream
src/store/index.tssetActivePlugin auto-pairing, syncProviderSession
src/ui/ConnectionHud.tsxVenue label, pairing Fix button
src/ui/SettingsDialog.tsxData tab (exchange credentials)
src/ui/error-share.tsstripSecretKeys redaction
worker/src/market.tsSigned Binance klines proxy

Related docs


ADR lifecycle

New ADRs should:

  1. State context, decision, consequences.
  2. Cross-link code paths.
  3. Avoid re-litigating ADR-002 without supersession note.

See also