openvibers/openvibe.trade README.md
README.md
| 1 | # OpenVibe.Trade |
| 2 | |
| 3 | > Informational watchlists, sourced market context and alerts. No custody, no order execution. |
| 4 | |
| 5 | **Live site:** https://openvibe.trade · **API docs:** https://openvibe.services · **For AI:** https://openvibe.trade/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha (roadmap Wave 19). The service runs, its tests pass, and it is **public at its |
| 9 | domain, [openvibe.trade](https://openvibe.trade), since 2026-10-09**, after an independent |
| 10 | pre-launch security review whose fixes shipped first. Since 2026-10-15 it carries about 8,000 |
| 11 | US-listed companies from the SEC's own ticker list, their XBRL filings as they arrive, the key numbers |
| 12 | in those filings, and AI briefs that cite them (see "What it shows today"). Its capabilities and |
| 13 | service manifest are registered in openvibe-contracts. No price feed is configured. |
| 14 | **Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade` |
| 15 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11; |
| 16 | roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope). |
| 17 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 18 | |
| 19 | > **Information only — not investment advice; no trading here.** This sentence is on every page. |
| 20 | |
| 21 | ## Purpose |
| 22 | |
| 23 | An informational product: instruments and their aliases with deterministic resolution, market |
| 24 | observations that always show when they were observed, when they were retrieved and which source |
| 25 | stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert |
| 26 | rules, short AI briefs written from SEC's filed numbers and filings (labelled, not reviewed by a person), and |
| 27 | context written by editors (or drafted by OpenVibe.AI and reviewed by a person). |
| 28 | |
| 29 | Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no |
| 30 | listing between users and no personalised financial advice — not as a feature, not as an endpoint, |
| 31 | not as a table. The historical marketplace branch is recorded as an open question in |
| 32 | [docs/marketplace-open-question.md](docs/marketplace-open-question.md), not implemented. |
| 33 | |
| 34 | ## Owns |
| 35 | |
| 36 | The nine charter tables (roadmap §15.13) live in Trade's own PostgreSQL database (`ov_trade` on the host's data role, ADR-035; schema in [migrations/](migrations/)): |
| 37 | |
| 38 | | Charter table | What it is | |
| 39 | |---|---| |
| 40 | | `trade_instruments` | Symbol (canonical, upper case), name, kind, exchange, SEC CIK, trading currency, status, and which context revision is published. Companies come from SEC's own ticker list (Sources `sec-company-tickers`, created by `svc:trade`: one per CIK, other share classes as ticker aliases); currencies from the rates; anything else from an editor. | |
| 41 | | `trade_instrument_aliases` | Tickers, CIKs and names with their normalised resolution key. A ticker or CIK names exactly one instrument (unique index); a name may be shared (resolution is then ambiguous). | |
| 42 | | `trade_watchlists` | Private, one owner (`usr_…`). | |
| 43 | | `trade_watchlist_items` | Instruments on a watchlist, with an optional note. | |
| 44 | | `trade_market_observations` | Value (the decimal exactly as stated), unit, currency, period, `observed_at`, source key, Sources item, source URL, the source's own reference, `retrieved_at`, optional `max_age_sec`, who recorded it. **Immutable** (triggers abort UPDATE and DELETE); unique per `(source_key, source_ref)`. | |
| 45 | | `trade_source_documents` | Filings and other documents from Sources items: form type, filer, CIK, accession, URL, the source's own dates, `retrieved_at`, licence/terms notes, removal state. | |
| 46 | | `trade_alert_rules` | `threshold` (metric, above/below, threshold, unit, currency), `filing_type` (form types) or `new_document`, one owner. | |
| 47 | | `trade_alert_deliveries` | One row per `(rule, trigger kind, trigger id)` under a UNIQUE key — the idempotency of alerts. | |
| 48 | | `trade_context_revisions` | `openvibe-publishing/revisions` (prefix `trade_context`): immutable revisions of an instrument's context with authorship, `as_of` and citations of Trade's documents and observations. | |
| 49 | |
| 50 | Other tables in the same database: |
| 51 | |
| 52 | - Package companions: `trade_context_drafts`, `trade_context_revision_purges`, |
| 53 | `trade_context_reviews` (a person's review of an AI draft) and `trade_index_revisions` (the |
| 54 | Search document sequencer). |
| 55 | - `trade_source_status`: freshness of each source (Sources' health plus Trade's own clock). |
| 56 | - `trade_ingest_cursor`: the change cursor of the Sources feed (`openvibe-publishing/ingest`). |
| 57 | - `trade_sync_state`: the last sync error and counters of what was skipped and why. |
| 58 | - `event_outbox` (SDK outbox) and `idempotency_receipts` (SDK inbox for signed webhook deliveries). |
| 59 | - `telemetry_samples` (migration `0005_telemetry.sql`): request telemetry, openvibe-sdk 0.46 |
| 60 | `platform.telemetry-sample@1` shape. Each request is aggregated by route template, method and |
| 61 | status class, never by raw URL; `/shared`, static assets, health, readiness and `/metrics` are not |
| 62 | counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; |
| 63 | every minute and on stop `server/index.js` writes one `http.request` sample per key plus the |
| 64 | active-requests, p95 latency and event-loop-lag gauges, and deletes rows older than 14 days daily. |
| 65 | |
| 66 | ## Does not own |
| 67 | |
| 68 | - **Money, custody, orders, escrow, listings, advice.** None exist here (ADR-025). Money belongs to |
| 69 | OpenVibe.Billing (ADR-012). |
| 70 | - **Source items.** OpenVibe.Sources owns ingestion, robots, terms and the items; Trade keeps the |
| 71 | metadata it shows, keyed by the item id, and follows revisions and removals. |
| 72 | - **Identity.** OpenVibe.Network: SSO, subjects, service principals. |
| 73 | - **Notifications.** Network renders and delivers them from `trade.alert.triggered` (ADR-020). |
| 74 | Trade sends no email. |
| 75 | - **Search.** OpenVibe.Search indexes what Trade sends through Events. |
| 76 | - **AI generation.** OpenVibe.AI runs `trade.summarize_market_context`; Trade makes no provider calls. |
| 77 | - **Discussion.** Community (not wired yet; no comments here). |
| 78 | |
| 79 | ## What works |
| 80 | |
| 81 | - **Instruments and resolution.** Editors add instruments (form or API). Resolution is exact and |
| 82 | deterministic, in a fixed order: CIK (all digits, optionally `CIK…`) → ticker (symbol, then ticker |
| 83 | aliases) → exact normalised name (diacritics, case, punctuation, `&`, `/DE/` and trailing corporate |
| 84 | words normalised). Several instruments with the same name → `ambiguous`, candidates listed, none |
| 85 | chosen. No fuzzy or prefix matching. `/i/aapl` and a ticker alias answer 301 to `/i/AAPL`. |
| 86 | - **Observations.** Recorded by a first-party feed (`POST /api/v1/observations`, |
| 87 | `trade.observation.write`) or from Sources items that state a value: the explicit `fields.metric`, |
| 88 | `value`, `unit`, `observed_at` (a symbol or CIK), or a rate the real trade sources state — the api |
| 89 | adapter's `fields.rate` with `base`/`quote` (`frankfurter-eur-rates`), or a `<rate> <QUOTE> = 1 |
| 90 | <BASE>` reference-rate title (`ecb-euro-reference-rates`, the value and currencies read as stated). |
| 91 | A bare date is that day at 00:00 UTC. `observed_at` and `retrieved_at` are required and never |
| 92 | defaulted; a replay is `200 created:false`; the same reference with a different value is `409`. A |
| 93 | rate's quote currency with no instrument yet registers one (kind `currency`, symbol/name the stated |
| 94 | ISO code); every other item must match an editor-created instrument or it is skipped and counted. |
| 95 | **No observation → no number:** the page says "No observations have been recorded for X, so no |
| 96 | number is shown." |
| 97 | - **Freshness.** Per source: fresh only while the last successful fetch is younger than the source's |
| 98 | window (Sources' `stale_after_sec`, else `TRADE_DEFAULT_STALE_AFTER_SEC`), computed with Trade's |
| 99 | clock. A stale value stays visible with its timestamps and a **"stale since <time>"** badge; a |
| 100 | source Trade has never heard of is "freshness unknown". An observation can also carry its own |
| 101 | `max_age_sec`. `trade.source.stale` / `.recovered` are emitted on transitions only. If Sources or |
| 102 | the sync fails, nothing is invented: the failure is recorded, and the data ages into "stale". |
| 103 | - **Documents.** The sync pulls `category=trade` items by cursor. SEC EDGAR filings (the seeded |
| 104 | `sec-xbrl-filings` source) are recognised by their `sec.gov/Archives/edgar/data/<CIK>/<accession>/` |
| 105 | URL: CIK and accession come from the URL, the form type from the item summary (the feed's |
| 106 | `<description>`) when it looks like one, the filer from the title. A filing without a date says |
| 107 | "not stated by the source". Items that match no instrument are skipped and counted, never stored. |
| 108 | A Sources removal hides the document (the row stays as the record). If the sync ever advances the |
| 109 | cursor past items that were skipped and later fixed, rewind it with |
| 110 | `node scripts/rewind-sync-cursor.js` (dry run; `--apply` to move it) — re-reading is idempotent per |
| 111 | item revision. |
| 112 | - **Watchlists.** Private per person; plain forms and the API. Anyone else gets 404. |
| 113 | - **Alerts.** `threshold` rules fire on a crossing (armed → fired → re-armed when a newer |
| 114 | observation no longer meets the condition; late observations never fire; unit and currency must |
| 115 | match exactly, no conversion). `filing_type` and `new_document` fire once per newly seen document; |
| 116 | item revisions never re-fire. Each delivery is one row and one `trade.alert.triggered` event |
| 117 | (visibility `subject`, subject = the owner), in the same transaction as the triggering |
| 118 | observation or document. |
| 119 | - **Context.** Editors write Markdown context citing documents and observations (`as_of` = the |
| 120 | newest cited time, never the time of writing), publish, retract, and review. OpenVibe.AI output is |
| 121 | a labelled AI-generated draft, never attributed to a person, that cannot be published before a |
| 122 | person on the editor list approves it. Text that recommends buying, selling or holding, or gives |
| 123 | price targets, is refused (`context.advice_refused`). |
| 124 | |
| 125 | ### What it shows today |
| 126 | |
| 127 | Trade shows only what a source stated: |
| 128 | |
| 129 | - **Companies:** SEC's ticker list (Sources `sec-company-tickers`, daily): one instrument per CIK, other share |
| 130 | classes as ticker aliases (`domain/ingest.js`). |
| 131 | - **Filings:** SEC's XBRL filings feed (Sources `sec-xbrl-filings`, every 15 minutes), matched by CIK. |
| 132 | - **Numbers:** SEC's XBRL company facts, read straight from `data.sec.gov` (`clients/sec.js`, `domain/facts.js`) |
| 133 | when a company files a 10-K, 10-Q, 20-F or 40-F, or when a reader asks for a brief: |
| 134 | - revenue, net income, operating income, diluted EPS and operating cash flow for the last five quarters and two |
| 135 | years; |
| 136 | - assets, liabilities, equity, cash, long-term debt and shares outstanding at the last five quarter ends. |
| 137 | |
| 138 | Only SEC's framed facts are used (one per period), each an immutable observation with its period, its filing and |
| 139 | `source_key sec-companyfacts`. |
| 140 | - **Rates:** euro reference rates from the ECB and Frankfurter. |
| 141 | - **AI briefs** (`domain/briefs.js`, OpenVibe.AI `trade.summarize_market_context`): |
| 142 | - written from a company's numbers and filings list only, when a report brings new numbers (the worker, |
| 143 | `TRADE_BRIEFS_PER_TICK`) or when a reader asks (`POST /i/:symbol/brief`: same origin, 5 per address per 10 |
| 144 | minutes), within `TRADE_BRIEFS_PER_HOUR` for the whole site; |
| 145 | - the worker's report brief asks OpenVibe.AI for the `batch` latency class: the run id and the exact inputs are |
| 146 | kept in `trade_ai_brief_jobs` (migration `0009`), the answer is collected on a later tick, and a refused or |
| 147 | expired batch falls back to the `background` class once (`TRADE_BRIEFS_BATCH=off` keeps it synchronous; |
| 148 | `TRADE_BRIEFS_BATCH_DEADLINE_HOURS` defaults to 3: a market brief older than that is stale, so it falls back to background). A reader's ask stays synchronous; |
| 149 | - every kept point cites Trade's own observation or document rows, and an uncited point is dropped and listed; |
| 150 | - text that reads as advice is refused, and so is a placeholder run; |
| 151 | - shown labelled "AI-written, not reviewed by a person". They do not make a page indexable: that still needs a |
| 152 | person-reviewed context. |
| 153 | - **Market brief** (`domain/market-briefs.js`, the same OpenVibe.AI `trade.summarize_market_context` workflow): one |
| 154 | a day at most (a UTC day, `TRADE_MARKET_BRIEFS=off` turns it off), written only from what Trade stored — the day's |
| 155 | filings by form, the euro reference rates, the companies with new filings or SEC figures and aggregate watchlist |
| 156 | activity. Every kept point links to its Trade page; no prices are sent. Each brief has its own page |
| 157 | `/market/<YYYY-MM-DD>`: unreviewed it carries "AI-written, not yet reviewed by a person" and is noindex through the |
| 158 | shared gate; once an editor approves it from `GET /api/v1/review-queue` (kind `market_brief`) the page is indexable |
| 159 | and enters the pages sitemap. The home never changes indexability because of a brief: it shows the latest reviewed |
| 160 | one in full (with its date), or a one-line link to the unreviewed page while none is reviewed. Without OpenVibe.AI |
| 161 | nothing is made and nothing renders. |
| 162 | - **No prices:** no price feed is configured. |
| 163 | |
| 164 | ### Routes (server-rendered, useful without JavaScript) |
| 165 | |
| 166 | | Route | What | |
| 167 | |---|---| |
| 168 | | `/` | the front page (`server/render/home.js`): a company search and the site's numbers, the latest reviewed daily market brief (or a link to the unreviewed one), the latest AI briefs, euro reference rates (each currency's newest rate, its date and source, and the change from the same source's previous rate), new 10-K/10-Q reports, the last 7 days' filings by form (plain names), the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD); `/?page=N` answers 301 to `/instruments?page=N` | |
| 169 | | `/market/<YYYY-MM-DD>` | one market brief: unreviewed it is noindex and labelled; a person's approval makes it indexable | |
| 170 | | `/instruments` | every instrument, the newest filing or figure first (the front page's old list), 50 a page | |
| 171 | | `/filings`, `/filings?form=` | the newest SEC filings, 50 a page, with the last 7 days' counts by form; one form's list is noindex with `/filings` as its canonical | |
| 172 | | `/resolve?q=` | resolution; one match → 302 to the instrument | |
| 173 | | `/i/:symbol`, `/i/:symbol.json` | the instrument page (observations with timestamps and sources, context with disclosure, documents, source freshness, watch/alert forms), and the same data as JSON | |
| 174 | | `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed | |
| 175 | | `/feed.xml`, `/atom.xml`, `/feed.json` | recent documents across instruments | |
| 176 | | `/sources` | freshness of every source and of the sync | |
| 177 | | `/watchlists` | your watchlists, alert rules and deliveries (private, noindex, `private, no-store`) | |
| 178 | | `/editor`, `/editor/i/:symbol` | the editor (plain forms, editors only) | |
| 179 | | `/sitemap.xml` → `/sitemaps/instruments.xml` | indexable instrument pages only | |
| 180 | | `/robots.txt`, `/llms.txt`, `/llms-full.txt` | crawler policy (private paths disallowed), orientation, and a short summary of every indexable instrument | |
| 181 | | `/terms`, `/privacy`, `/dmca` | openvibe-shared legal pages | |
| 182 | | `/auth/*` | sign-in; the same session layer as Blog and Community | |
| 183 | | `/api/health`, `/api/ready`, `/release.json`, `POST /release-metrics`, `/metrics` | health, readiness, release, open tabs' update reports (counted in `/metrics`), metrics (loopback only) | |
| 184 | | `POST /internal/events` | signed OpenVibe.Events deliveries (`sources.*`, and the two ADR-033 account events), host-local | |
| 185 | |
| 186 | Sitemap entries carry lastmod from each instrument's latest recorded change. |
| 187 | |
| 188 | ### Discoverability (roadmap §32) |
| 189 | |
| 190 | - Instrument pages go through the `openvibe-publishing/seo` gate. Financial information is a |
| 191 | **sensitive** category: a page is `noindex` (`unreviewed_sensitive`) until a person-reviewed |
| 192 | context is published, `noindex` when its newest monetary observation is older than |
| 193 | `TRADE_PRICE_MAX_AGE_SEC` (`stale_price`), and `thin` below `TRADE_GATE_MIN_WORDS` of context. |
| 194 | - The sitemap lists indexable instrument pages only, `lastmod` = the instrument's latest recorded change. |
| 195 | - JSON-LD: `WebPage` (+ `Corporation` with `tickerSymbol` for equities) and breadcrumbs, from real |
| 196 | fields only. No price, offer, rating or invented date. |
| 197 | - Every instrument page has a JSON twin with the same data (`/i/:symbol.json`). |
| 198 | |
| 199 | ### IndexNow (openvibe-shared/indexnow) |
| 200 | |
| 201 | - With `INDEXNOW_KEY` set, the key file is served at `/<key>.txt` as `text/plain`. |
| 202 | - Whenever the gate's verdict for an instrument page changes — a person-reviewed context is published |
| 203 | or updated, the context is retracted, the instrument is archived, or a price ages past |
| 204 | `TRADE_PRICE_MAX_AGE_SEC` — Trade pings `api.indexnow.org` with the page's canonical URL and |
| 205 | `/sitemap.xml`; the module batches and debounces (one POST per 30s window). A failed ping never |
| 206 | takes a publish down. |
| 207 | - Drafts, unreviewed AI context, private pages and noindex pages never ping. Unset key: off — no key |
| 208 | file, no requests. |
| 209 | |
| 210 | ### Caching (nothing personal in shared caches) |
| 211 | |
| 212 | - `public, max-age=60` only for pages identical for everyone, served to anonymous viewers. |
| 213 | - `private, no-store` for everything personal (signed-in views, watchlists, alerts, editor), every |
| 214 | error, every form response and the whole API. HTML varies on `Cookie` and `Authorization`. |
| 215 | - Feeds and sitemaps are public for 5 minutes and contain public documents and pages only. |
| 216 | |
| 217 | ### API `/api/v1` (problem+json errors) |
| 218 | |
| 219 | | Capability | Routes | |
| 220 | |---|---| |
| 221 | | (public) | `GET /instruments`, `GET /instruments/:symbol`, `GET /instruments/:symbol/observations`, `GET /instruments/:symbol/documents`, `GET /sources` | |
| 222 | | `trade.instrument.resolve` | `GET /instruments/resolve?q=&kind=` | |
| 223 | | `trade.instrument.manage` | `POST /instruments`, `PATCH /instruments/:symbol`, `POST /instruments/:symbol/aliases` (people: editors) | |
| 224 | | `trade.observation.write` | `POST /observations` (services only) | |
| 225 | | `trade.context.read` | `GET /instruments/:symbol/context` (`?all=1` drafts), `GET /instruments/:symbol/context/input` (the AI workflow input) | |
| 226 | | `trade.context.propose` | `POST /instruments/:symbol/context` (`X-OV-Origin: ai` for AI drafts; editors) | |
| 227 | | people on the editor list | `POST /instruments/:symbol/context/revisions/:n/review` | |
| 228 | | `trade.watchlist.read` / `.create` / `.update` / `.delete` | `GET/POST /watchlists`, `GET/PATCH/DELETE /watchlists/:id`, `PUT/DELETE /watchlists/:id/items/:symbol` | |
| 229 | | `trade.alert.read` / `.create` / `.delete` | `GET /alerts`, `GET /alerts/deliveries`, `POST /alerts`, `DELETE /alerts/:id` | |
| 230 | |
| 231 | Service tokens use audience `openvibe.trade`, one capability per route; private data needs |
| 232 | `X-OV-Subject` (the person the service acts for). Browsers with a Network JWT are judged by ownership |
| 233 | and the editor list. The charter's `trade.watchlist.create|update`, `trade.instrument.resolve`, |
| 234 | `trade.alert.create|delete` and `trade.context.read` are all 3-segment ids here; `read`, `delete`, |
| 235 | `manage`, `observation.write` and `context.propose` are additions. They and the service manifest |
| 236 | are released in openvibe-contracts (pinned at v0.97.0; the source proposals stay in |
| 237 | [docs/capabilities-proposal/](docs/capabilities-proposal/) and |
| 238 | [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)). Grants for these ids are |
| 239 | decided with the contracts library's matching rule (`server/auth/capabilities.js`). |
| 240 | |
| 241 | ### Events (SDK outbox, same transaction as the change) |
| 242 | |
| 243 | | Event | Visibility | Notes | |
| 244 | |---|---|---| |
| 245 | | `trade.observation.created` | public | instrument, metric, value, unit, currency, observed_at, retrieved_at, source | |
| 246 | | `trade.alert.triggered` | subject (the owner) | delivery id, rule, instrument, trigger with its timestamps and source; data only, no rendered text (ADR-020) | |
| 247 | | `trade.source.stale`, `trade.source.recovered` | public | on transitions only, with `stale_since` | |
| 248 | | `trade.index_document.upserted` / `.deleted` | internal | `search.index-document@1` documents of listable instrument pages, tombstones otherwise | |
| 249 | |
| 250 | Consumed: `sources.item.created|updated|removed` and `sources.fetch.failed` through the signed |
| 251 | webhook, as a wake-up for the cursor sync (the webhook is not durable truth; the sync is). |
| 252 | |
| 253 | ### Account export and deletion (ADR-033) |
| 254 | |
| 255 | `network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events` and are answered by |
| 256 | `server/domain/account-data.js` (`openvibe-sdk/account-data`). |
| 257 | |
| 258 | - **Export:** the person's watchlists and their items, alert rules and deliveries, context drafts, the revisions they |
| 259 | wrote, the observations they recorded by hand, and their reviews. |
| 260 | - **Deleted:** watchlists (items cascade), alert rules with their deliveries, and drafts. |
| 261 | - **Made authorless:** instruments, aliases and purges they made. Context revisions and observations stay append-only: |
| 262 | only inside the erasure transaction (`trade.account_erasure`, migration `0004_account_erasure.sql`) may a revision's |
| 263 | author become NULL and their id leave `meta.authorship.authors`, or an observation's `recorded_by` become `deleted`. |
| 264 | The text, the values and their provenance never change. |
| 265 | - **Kept, counted as retained:** reviews, since a person's approval is what lets reviewed text stay published. |
| 266 | - **Subscriptions:** the two are created at boot when missing (needs `EVENTS_URL` and `TRADE_EVENTS_WEBHOOK_SECRET`). |
| 267 | - **Receipts:** `account_data_events` keeps one per export and deletion. |
| 268 | |
| 269 | ### OpenVibe.AI seam (`trade.summarize_market_context`) |
| 270 | |
| 271 | 1. The AI (or its caller) reads `GET /api/v1/instruments/:symbol/context/input` — the workflow input |
| 272 | with numbered sources: each observation and document with `observed_at`/`published_at`, |
| 273 | `retrieved_at` and its staleness. |
| 274 | 2. The AI service posts the output to `POST /api/v1/instruments/:symbol/context` with |
| 275 | `X-OV-Origin: ai`, `workflow { id, run_id }`, `input_sources` and `output` (the workflow's |
| 276 | schema). Citations are indices into `input_sources` and must exist. |
| 277 | 3. The revision is an AI-generated **draft**: labelled, noindex, not shown to readers until a person |
| 278 | on the editor list approves it at `/editor/i/:symbol`. |
| 279 | |
| 280 | ## Capabilities |
| 281 | |
| 282 | Implemented here (the service manifest's `capabilities`, audience `openvibe.trade`, one per route; the |
| 283 | route table is under [API](#api-apiv1-problemjson-errors)): `trade.instrument.resolve`, |
| 284 | `trade.instrument.manage`, `trade.observation.write`, `trade.context.read`, `trade.context.propose`, |
| 285 | `trade.watchlist.read`, `.create`, `.update`, `.delete`, and `trade.alert.read`, `.create`, `.delete`. |
| 286 | None of them moves, holds or orders anything (ADR-025; `test/route-inventory.test.js`). |
| 287 | |
| 288 | Called elsewhere, as the service principal `trade`: `events.event.publish` (Events) and |
| 289 | `sources.item.read` and `sources.source.read` (Sources). The list with audiences, and the grants other |
| 290 | services need to call Trade, is under [Grants the Network must hold](#grants-the-network-must-hold). |
| 291 | |
| 292 | ## Depends on |
| 293 | |
| 294 | - **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through |
| 295 | `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process). |
| 296 | - **Packages** (pinned by release tarball): `openvibe-contracts` v0.97.0, `openvibe-publishing` |
| 297 | v1.3.0 (revisions, authorship, seo, index-hooks, ssr, ingest, publication), `openvibe-shared` v4.179.0 (Frame, app icon, |
| 298 | footer, legal, release, metrics, ready), `openvibe-sdk` v0.150.0 (events outbox and inbox, webhook |
| 299 | signatures v2, service tokens, per-actor limits, service kit lifecycle). |
| 300 | - **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`), |
| 301 | JWKS, client-credentials tokens. |
| 302 | - **OpenVibe.Sources:** `sources.item.read` and `sources.source.read` (category `trade`). |
| 303 | - **OpenVibe.Events:** `events.event.publish`; a subscription for `sources.*` delivering to |
| 304 | `http://127.0.0.1:4860/internal/events` (optional; the sync also runs on a timer). |
| 305 | - **OpenVibe.Search:** consumes `trade.index_document.*` (Search's `*.index_document.*` subscription; |
| 306 | `trade` must be in `SEARCH_EVENT_OWNERS`). |
| 307 | - **OpenVibe.Network notifications:** Network consumes `trade.alert.triggered` (its `network` |
| 308 | subscription) and puts a "Trade alert: <symbol>" notification in the rule owner's inbox, linking the |
| 309 | instrument page; deliveries are also listed at `/watchlists`. |
| 310 | - **OpenVibe.AI:** `ai.run.create` (namespace `trade`) for the AI briefs, including the `batch` class for the |
| 311 | worker's report briefs (`OV_AI_INTERNAL_URL`; off without it), and optionally the drafts below. |
| 312 | - **SEC EDGAR** (`data.sec.gov`, read directly, no key): XBRL company facts, public domain. Fair access: the |
| 313 | User-Agent `TRADE_SEC_USER_AGENT` names us and a contact; requests are spaced `TRADE_SEC_MIN_INTERVAL_MS` (1 s) |
| 314 | apart, at most `TRADE_SEC_PER_TICK` companies a tick. `TRADE_SEC_FACTS=off` turns it off. |
| 315 | |
| 316 | ### Grants the Network must hold |
| 317 | |
| 318 | Each grant is `[client, capability, audience]`: |
| 319 | |
| 320 | - `[trade, events.event.publish, openvibe.events]` |
| 321 | - `[trade, events.subscription.manage, openvibe.events]` (also the account subscriptions at boot) |
| 322 | - `[trade, network.account.export.contribute, openvibe.network]` and `[trade, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live) |
| 323 | - `[trade, sources.item.read, openvibe.sources]` |
| 324 | - `[trade, sources.source.read, openvibe.sources]` |
| 325 | - `[trade, ai.run.create, openvibe.ai, ['trade.*']]` (the AI briefs) |
| 326 | - For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and |
| 327 | `[ai, trade.context.propose, openvibe.trade]` |
| 328 | - For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]` |
| 329 | - For Network (or another first-party surface) to show a person's watchlists and alerts: |
| 330 | `[network, trade.watchlist.read, openvibe.trade]`, `[network, trade.alert.read, openvibe.trade]` (optional) |
| 331 | |
| 332 | ## Acceptance (automated: `npm test`) |
| 333 | |
| 334 | | Charter / roadmap / ADR-025 requirement | Test | |
| 335 | |---|---| |
| 336 | | **No execution, custody or advice endpoint exists**: a route-inventory test walks the live Express stack and fails on any route path, handler name, capability id or event type with order/buy/sell/execute/custody/wallet/escrow/checkout/listing semantics; every route has a named handler; a negative control proves it bites. | `test/route-inventory.test.js` | |
| 337 | | Every datum exposes observation and source timestamps; **no number without an observation**; times are required, never defaulted; observations immutable and idempotent per source reference. | `test/observations.test.js` | |
| 338 | | **Stale feeds shown as stale** (injected clock): "stale since" = last success + window; unknown freshness is not "current"; transitions emit once; a Sources outage invents nothing; `stale_price` in the gate. | `test/freshness.test.js` | |
| 339 | | **Alerts idempotent per triggering observation** (and per document): replayed observations, re-evaluation, replayed sync pages and item revisions deliver nothing new; crossing semantics; privacy of rules; no email. | `test/alerts.test.js` | |
| 340 | | **Watchlists never leak**: private cache headers, noindex, 404 for others, CSRF form tokens, capability + X-OV-Subject for services, never in sitemaps, feeds, Search or events. | `test/privacy.test.js` | |
| 341 | | AI output is a labelled draft, noindex until a person approves it; advice refused; editor forms; sitemap/Search follow publication. | `test/context.test.js` | |
| 342 | | Sources mapping (SEC filings), deterministic resolution, cursor replay, removals, signed webhook with inbox. | `test/sync.test.js` | |
| 343 | | Disclaimer on every page; no-JS pages; JSON twin = HTML; feeds without invented dates; robots/llms/sitemaps; readiness; the marketplace question is documented and nothing of it exists. | `test/pages.test.js` | |
| 344 | | IndexNow: off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing or retracting indexable context pings the instrument path and the sitemap; a draft never pings. | `test/indexnow.test.js` | |
| 345 | | The home page's first load stays inside its size budgets (html/js/css, fresh database, no browser). | `test/perf-budget.test.js` | |
| 346 | | Proposals valid against the released schemas and equal to what the code enforces and emits; every envelope valid. | `test/contracts.test.js` | |
| 347 | |
| 348 | ## Launch rule |
| 349 | |
| 350 | The launch rule (plan §12.12) is met: `openvibe.trade` went public on 2026-10-09, after an |
| 351 | independent pre-launch security review whose fixes shipped first. |
| 352 | [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) was deleted (plan T11), so |
| 353 | the domain serves the service itself. Status against each point: |
| 354 | |
| 355 | 1. **Runtime, health, readiness, observability:** done. |
| 356 | 2. **Canonical identity and auth:** done (Network SSO, subjects, service tokens). |
| 357 | 3. **SSR public routes useful without JS:** done. |
| 358 | 4. **Persistence and end-to-end workflows:** done in tests and live on the host (empty database |
| 359 | until editors add instruments); in production there is no market data source yet and the filings |
| 360 | source is disabled in Sources (see "What it shows today"). |
| 361 | 5. **Capability and event registration against OpenVibe.Contracts:** done. |
| 362 | 6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothing to |
| 363 | migrate (no current implementation); no seed (editors add instruments); threat review below. |
| 364 | 7. **Acceptance tests:** done. |
| 365 | |
| 366 | The launch release removed `openvibe.trade` from `OpenVibe.Sites/sites.json` and switched routing |
| 367 | (nginx vhost, DNS, TLS), flipped the Network hub entry and registered maturity in the ecosystem |
| 368 | registry, atomically, on 2026-10-09. OpenVibe.Sites is deleted (plan T11). |
| 369 | |
| 370 | ## Security and threat review |
| 371 | |
| 372 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 373 | |
| 374 | - **Scope (ADR-025):** nothing moves or holds value. The route-inventory test is in CI; context that |
| 375 | reads as advice is refused; the disclaimer is on every page. |
| 376 | - **Identity:** only verified Network JWTs (offline RS256 against JWKS) and service tokens for |
| 377 | audience `openvibe.trade`; a bad service token is refused, never downgraded to anonymous; identity |
| 378 | never comes from a body or query; `X-OV-*` headers are ignored for browsers. |
| 379 | - **Private data:** watchlists and alert rules are owner-only (404 otherwise), `private, no-store`, |
| 380 | noindex, disallowed in robots, absent from sitemaps, feeds, Search documents and events (the |
| 381 | alert event goes to its owner only, visibility `subject`). |
| 382 | - **CSRF:** SameSite=Lax session cookie plus an HMAC form token (`TRADE_FORM_SECRET`) on every form. |
| 383 | - **XSS:** all HTML through `openvibe-publishing/ssr` auto-escaping; context Markdown through its |
| 384 | safe subset; external links `rel="nofollow noopener"`; CSP from helmet. |
| 385 | - **Honesty:** observation and retrieval times are required; values are stored exactly as stated; |
| 386 | observations are immutable; documents never get invented dates; JSON-LD only from real fields; |
| 387 | AI drafts are labelled and gated by a person's review. |
| 388 | - **SSRF:** Trade fetches only its configured Network and Sources hosts; it never fetches a URL from |
| 389 | a source item (it links to it). |
| 390 | - **Webhook:** signature v2 only (HMAC over `<timestamp>.<raw body>`, timestamp within the replay |
| 391 | window; a v1-only or stale delivery is refused), inbox per event id, host-local only (nginx returns 404). |
| 392 | - **Abuse:** rate limits on `/auth`, private forms and `/api/v1`, in Express and in the nginx |
| 393 | reference; per-actor limits (below); per-person limits of 20 watchlists × 200 instruments and 100 |
| 394 | alert rules. |
| 395 | - **Known gaps:** the advice filter is a phrase guard, not a guarantee (editors remain accountable); |
| 396 | there is no market data source yet; Network does not consume `trade.alert.triggered` yet; Search |
| 397 | documents exist only for instruments with reviewed context. |
| 398 | |
| 399 | ### Per-actor limits |
| 400 | |
| 401 | `/api/v1` and the watchlist, alert and editor forms also limit who calls them, once `req.viewer` is |
| 402 | resolved and a route's capability guard (for a form, the session and form token) passed, before any |
| 403 | work: `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. Counted: a person as |
| 404 | `user:usr_…` (their own token or cookie, a first-party service naming them in `X-OV-Subject`, or an |
| 405 | app's `on_behalf_of`); a service or app acting as itself (a feed, OpenVibe.AI) by its principal; |
| 406 | anyone else by address. Signed-out reads keep only the per-address limit (many readers share a |
| 407 | carrier or campus address), and a first-party service reading for itself is not counted on reads. |
| 408 | Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one log line and |
| 409 | `trade_rate_limited_total{limit,window}`. A form and the API route that do the same thing share one |
| 410 | budget. |
| 411 | |
| 412 | | Routes (API and form) | Per caller, a minute / an hour | |
| 413 | |---|---| |
| 414 | | Signed-in API reads | `TRADE_LIMITS_MINUTE` / `TRADE_LIMITS_HOUR` (120 / 3000) | |
| 415 | | Instrument create, edit, aliases | 30 / 300 | |
| 416 | | `POST /observations` (a feed) | 240 / 10 000 | |
| 417 | | Context proposals and editor context revisions | 60 / 1200 | |
| 418 | | Context review, publish, retract | 30 / 300 | |
| 419 | | Watchlist create | 10 / 100 | |
| 420 | | Watchlist rename, delete, add and remove items | 60 / 600 | |
| 421 | | Alert create | 20 / 100 | |
| 422 | | Alert delete | 60 / 600 | |
| 423 | |
| 424 | Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/release-metrics`, `/metrics`, |
| 425 | sign-in, the pages and feeds people read, and the signed Events deliveries at `/internal/events`. |
| 426 | `test/actor-limits.test.js`. |
| 427 | |
| 428 | ## Development |
| 429 | |
| 430 | ```bash |
| 431 | fnm exec --using=22.22.1 npm install |
| 432 | fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network |
| 433 | fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTH_CLIENT_SECRET to sign in) |
| 434 | ``` |
| 435 | |
| 436 | ## Deploy (for the lead) |
| 437 | |
| 438 | Production deploys with `sudo ovhost deploy trade` on the host (strategy `git-checkout`: fetch, |
| 439 | fast-forward `/opt/openvibe.trade`, install on a lockfile change, restart, wait for `/api/ready`). |
| 440 | The unit is `openvibe-trade.service` on `127.0.0.1:4860`, the env file `/etc/openvibe/trade.env`. The database is |
| 441 | `ov_trade` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh trade` writes its settings); the |
| 442 | release migrates it at boot. |
| 443 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 444 | restart; afterwards `sudo ovhost rollback trade --to <sha>`. Migrations only add tables and columns. |
| 445 | |
| 446 | First install (done once; kept for a rebuild): |
| 447 | |
| 448 | 1. **Code and config:** code at `/opt/openvibe.trade`, `npm ci --omit=dev` on Node 22. |
| 449 | Create `/etc/openvibe/trade.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`, |
| 450 | `TRADE_EDITORS`, `TRADE_FORM_SECRET`, `EVENTS_URL=http://127.0.0.1:4300`, |
| 451 | `BASE_URL=https://openvibe.trade`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720` and, if the |
| 452 | Events subscription is created, `TRADE_EVENTS_WEBHOOK_SECRET`. |
| 453 | 2. **Network:** register (or seed) the OAuth client `trade` with redirect |
| 454 | `https://openvibe.trade/auth/callback`, set its secret, add the grants above. |
| 455 | 3. **Events:** `node scripts/subscribe.js` creates the subscription for consumer `trade`, topic |
| 456 | `sources.*`, endpoint `http://127.0.0.1:4860/internal/events` (loopback: nginx never proxies |
| 457 | `/internal/`), secret = `TRADE_EVENTS_WEBHOOK_SECRET` (32+ characters). It needs |
| 458 | `OV_OAUTH_CLIENT_SECRET` and the `[trade, events.subscription.manage, openvibe.events]` grant. |
| 459 | Pass `--endpoint <url>` when the Events host differs. |
| 460 | 4. **Search:** add `trade` to `SEARCH_EVENT_OWNERS` if it is not there. |
| 461 | 5. **systemd:** install `deploy/systemd/openvibe-trade.service` (port 4860, `StateDirectory=openvibe-trade`). |
| 462 | 6. **nginx:** install `deploy/nginx/openvibe.trade.conf` (`/metrics` and `/internal/` never proxied). |
| 463 | 7. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0, |
| 464 | and CI's contracts check runs against them. |
| 465 | 8. **Data:** editors add instruments at `/editor`. Filings appear once `sec-xbrl-filings` is enabled |
| 466 | in Sources (after a person verifies its terms); numbers appear only once a market data source |
| 467 | exists and states them. |
| 468 | 9. **Launch:** done on 2026-10-09: `openvibe.trade` was removed from OpenVibe.Sites (now deleted, |
| 469 | plan T11) and the Network hub entry flipped (see the launch rule). |
| 470 | |
| 471 | --- |
| 472 | |
| 473 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 474 | |
| 475 | <!-- versions:start --> |
| 476 | - openvibe-contracts: v0.419.0 |
| 477 | - openvibe-sdk: v0.161.0 |
| 478 | - openvibe-shared: v4.241.0 |
| 479 | - openvibe-publishing: v1.7.0 |
| 480 | <!-- versions:end --> |
| 481 |