openvibers/openvibe.trade README.md

README.md

main 35.8 KB · 481 lines · markdown

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
9domain, [openvibe.trade](https://openvibe.trade), since 2026-10-09**, after an independent
10pre-launch security review whose fixes shipped first. Since 2026-10-15 it carries about 8,000
11US-listed companies from the SEC's own ticker list, their XBRL filings as they arrive, the key numbers
12in those filings, and AI briefs that cite them (see "What it shows today"). Its capabilities and
13service 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;
16roadmap 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
23An informational product: instruments and their aliases with deterministic resolution, market
24observations that always show when they were observed, when they were retrieved and which source
25stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert
26rules, short AI briefs written from SEC's filed numbers and filings (labelled, not reviewed by a person), and
27context written by editors (or drafted by OpenVibe.AI and reviewed by a person).
28
29Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no
30listing between users and no personalised financial advice — not as a feature, not as an endpoint,
31not 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
36The 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
50Other 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
127Trade 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
186Sitemap 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
231Service 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
233and 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
236are 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
239decided 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
250Consumed: `sources.item.created|updated|removed` and `sources.fetch.failed` through the signed
251webhook, 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
2711. 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.
2742. 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.
2773. 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
282Implemented here (the service manifest's `capabilities`, audience `openvibe.trade`, one per route; the
283route 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`.
286None of them moves, holds or orders anything (ADR-025; `test/route-inventory.test.js`).
287
288Called 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
290services 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
318Each 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
350The launch rule (plan §12.12) is met: `openvibe.trade` went public on 2026-10-09, after an
351independent pre-launch security review whose fixes shipped first.
352[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) was deleted (plan T11), so
353the domain serves the service itself. Status against each point:
354
3551. **Runtime, health, readiness, observability:** done.
3562. **Canonical identity and auth:** done (Network SSO, subjects, service tokens).
3573. **SSR public routes useful without JS:** done.
3584. **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").
3615. **Capability and event registration against OpenVibe.Contracts:** done.
3626. **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.
3647. **Acceptance tests:** done.
365
366The 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
368registry, atomically, on 2026-10-09. OpenVibe.Sites is deleted (plan T11).
369
370## Security and threat review
371
372Reporting 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
402resolved and a route's capability guard (for a form, the session and form token) passed, before any
403work: `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
405app's `on_behalf_of`); a service or app acting as itself (a feed, OpenVibe.AI) by its principal;
406anyone else by address. Signed-out reads keep only the per-address limit (many readers share a
407carrier or campus address), and a first-party service reading for itself is not counted on reads.
408Past 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
410budget.
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
424Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/release-metrics`, `/metrics`,
425sign-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
431fnm exec --using=22.22.1 npm install
432fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
433fnm 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
438Production deploys with `sudo ovhost deploy trade` on the host (strategy `git-checkout`: fetch,
439fast-forward `/opt/openvibe.trade`, install on a lockfile change, restart, wait for `/api/ready`).
440The 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
442release migrates it at boot.
443Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
444restart; afterwards `sudo ovhost rollback trade --to <sha>`. Migrations only add tables and columns.
445
446First install (done once; kept for a rebuild):
447
4481. **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`.
4532. **Network:** register (or seed) the OAuth client `trade` with redirect
454 `https://openvibe.trade/auth/callback`, set its secret, add the grants above.
4553. **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.
4604. **Search:** add `trade` to `SEARCH_EVENT_OWNERS` if it is not there.
4615. **systemd:** install `deploy/systemd/openvibe-trade.service` (port 4860, `StateDirectory=openvibe-trade`).
4626. **nginx:** install `deploy/nginx/openvibe.trade.conf` (`/metrics` and `/internal/` never proxied).
4637. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,
464 and CI's contracts check runs against them.
4658. **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.
4689. **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
473Part 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