openvibers/openvibe.trade Commits
README (purpose, owns, does not own, depends on, acceptance, launch rule, threat review), STATUS alpha W19, .env.example, deploy (systemd unit, nginx vhost on 4860), CI on Node 22.22.1 (contracts check continue-on-error until release), capability and service manifest proposals, the marketplace open question (ADR-025)
Changes
nnew file mode 100644@@@ -0,0 +1,67 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.trade — environment configuration (names only; never commit values)++# Copy to .env for development. Production: /etc/openvibe/trade.env (0600).++# Information only: there is no payment, brokerage or custody configuration, by design (ADR-025).++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4860++HOST=127.0.0.1++NODE_ENV=development++# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it++BASE_URL=http://localhost:4860++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)++TRUST_PROXY=2++++# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)++TRADE_DB_PATH=./data/trade.db++++# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OAuth client `trade` (redirect https://openvibe.trade/auth/callback). The same client is Trade's++# service principal svc:trade for client-credentials grants (see README, "Grants").++OV_OAUTH_CLIENT_ID=trade++OV_OAUTH_CLIENT_SECRET=++# Production: https://openvibe.trade/auth/callback (default: BASE_URL + /auth/callback)++#OV_OAUTH_REDIRECT_URI=++# Cookies: Secure by default in production; set false for plain-http local development++#COOKIE_SECURE=false++# Signs the watchlist, alert and editor form tokens (CSRF). Unset: random per process.++#TRADE_FORM_SECRET=++++# Editors (create instruments, write context, review AI drafts): Network subjects, comma-separated.++# Network admins are editors too.++TRADE_EDITORS=++++# OpenVibe.Sources — the trade category (filings, market data feeds), read by cursor with the++# service token above (sources.item.read, sources.source.read). Off while the secret is unset.++OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720++#TRADE_SYNC_INTERVAL_MS=60000++#TRADE_SYNC_PAGE_LIMIT=200++#TRADE_SYNC_MAX_PAGES=20++++# Freshness: the window for a source that reports none, and the age after which a monetary++# observation makes an instrument page noindex (stale_price)++#TRADE_DEFAULT_STALE_AFTER_SEC=10800++#TRADE_PRICE_MAX_AGE_SEC=86400++#TRADE_FRESHNESS_INTERVAL_MS=60000++# Indexability: minimum words of reviewed context before an instrument page may be indexed++#TRADE_GATE_MIN_WORDS=60++++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++# Secret of the Events subscription (topic sources.*) that POSTs to /internal/events.++# Unset: the webhook answers 404 and the sync runs on its timer only.++#TRADE_EVENTS_WEBHOOK_SECRET=++++# Background work (Sources sync + freshness); off: TRADE_WORKER=off++#TRADE_WORKER=on++++# Browser origins that may call /api/v1 with a Bearer Network JWT++#API_CORS_ORIGINS=https://openvibe.network++++# release.json (openvibe-shared/release): the deployed commit when git is not available++#RELEASE_COMMIT=++#MIN_CLIENT_RELEASE=nnew file mode 100644@@@ -0,0 +1,22 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ # Includes test/route-inventory.test.js: the build fails if any route, handler, capability or++ # event carries order/buy/sell/execution/custody/wallet/escrow/checkout/listing semantics (ADR-025).++ - run: npm test++ # Trade's service manifest and capabilities are proposals (docs/) until the lead releases them++ # in openvibe-contracts; until then the check reports that the manifest is a placeholder.++ - run: npx openvibe-contracts-check --service trade --src server++ continue-on-error: true@@@ -2,74 +2,315 @@> Informational watchlists, sourced market context and alerts. No custody, no order execution.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.trade`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11.++**Status:** alpha (roadmap Wave 19). The service runs and its tests pass. It is **not deployed**,++`openvibe.trade` still shows its placeholder from OpenVibe.Sites, and its capabilities and service++manifest are proposals that the next openvibe-contracts release has to include. No market data++feed is configured yet (see "What it shows today").++**Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade`++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11;++roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope).**License:** AGPL-3.0 (same as every OpenVibe service).++> **Information only — not investment advice; no trading here.** This sentence is on every page.++## Purpose--An informational product first: symbol/entity resolution, user watchlists, sourced market/filing/catalyst context, threshold/event alerts and cited AI summaries with visible timestamps. Brokerage, custody or execution are out of scope unless a separate regulated design is approved; the historical marketplace branch is recorded as an open ADR, not implemented here.++An informational product: instruments and their aliases with deterministic resolution, market++observations that always show when they were observed, when they were retrieved and which source++stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert++rules, and short context written by editors (or drafted by OpenVibe.AI and reviewed by a person).++++Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no++listing between users and no personalised financial advice — not as a feature, not as an endpoint,++not as a table. The historical marketplace branch is recorded as an open question in++[docs/marketplace-open-question.md](docs/marketplace-open-question.md), not implemented.## Owns--- `trade_instruments`, `trade_instrument_aliases`, `trade_watchlists`, `trade_watchlist_items`, `trade_market_observations`, `trade_source_documents`, `trade_alert_rules`, `trade_alert_deliveries`, `trade_context_revisions`++The nine charter tables (roadmap §15.13) live in Trade's own SQLite (`TRADE_DB_PATH`):++++| Charter table | What it is |++|---|---|++| `trade_instruments` | Symbol (canonical, upper case), name, kind, exchange, SEC CIK, trading currency, status, and which context revision is published. |++| `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). |++| `trade_watchlists` | Private, one owner (`usr_…`). |++| `trade_watchlist_items` | Instruments on a watchlist, with an optional note. |++| `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)`. |++| `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. |++| `trade_alert_rules` | `threshold` (metric, above/below, threshold, unit, currency), `filing_type` (form types) or `new_document`, one owner. |++| `trade_alert_deliveries` | One row per `(rule, trigger kind, trigger id)` under a UNIQUE key — the idempotency of alerts. |++| `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. |++++Other tables in the same database:++++- Package companions: `trade_context_drafts`, `trade_context_revision_purges`,++ `trade_context_reviews` (a person's review of an AI draft) and `trade_index_revisions` (the++ Search document sequencer).++- `trade_source_status`: freshness of each source (Sources' health plus Trade's own clock).++- `trade_sync_state`: the Sources cursor, the last error and counters of what was skipped and why.++- `event_outbox` (SDK outbox) and `idempotency_receipts` (SDK inbox for signed webhook deliveries).## Does not own--- order execution, custody, personalised financial advice (none)--- discussion (Community)++- **Money, custody, orders, escrow, listings, advice.** None exist here (ADR-025). Money belongs to++ OpenVibe.Billing (ADR-012).++- **Source items.** OpenVibe.Sources owns ingestion, robots, terms and the items; Trade keeps the++ metadata it shows, keyed by the item id, and follows revisions and removals.++- **Identity.** OpenVibe.Network: SSO, subjects, service principals.++- **Notifications.** Network renders and delivers them from `trade.alert.triggered` (ADR-020).++ Trade sends no email.++- **Search.** OpenVibe.Search indexes what Trade sends through Events.++- **AI generation.** OpenVibe.AI runs `trade.summarize_market_context`; Trade makes no provider calls.++- **Discussion.** Community (not wired yet; no comments here).++++## What works++++- **Instruments and resolution.** Editors add instruments (form or API). Resolution is exact and++ deterministic, in a fixed order: CIK (all digits, optionally `CIK…`) → ticker (symbol, then ticker++ aliases) → exact normalised name (diacritics, case, punctuation, `&`, `/DE/` and trailing corporate++ words normalised). Several instruments with the same name → `ambiguous`, candidates listed, none++ chosen. No fuzzy or prefix matching. `/i/aapl` and a ticker alias answer 301 to `/i/AAPL`.++- **Observations.** Recorded by a first-party feed (`POST /api/v1/observations`,++ `trade.observation.write`) or from Sources items that state a value (`fields.metric`, `value`,++ `unit`, `observed_at`, a symbol or CIK). `observed_at` and `retrieved_at` are required and never++ defaulted; a replay is `200 created:false`; the same reference with a different value is `409`.++ **No observation → no number:** the page says "No observations have been recorded for X, so no++ number is shown."++- **Freshness.** Per source: fresh only while the last successful fetch is younger than the source's++ window (Sources' `stale_after_sec`, else `TRADE_DEFAULT_STALE_AFTER_SEC`), computed with Trade's++ clock. A stale value stays visible with its timestamps and a **"stale since <time>"** badge; a++ source Trade has never heard of is "freshness unknown". An observation can also carry its own++ `max_age_sec`. `trade.source.stale` / `.recovered` are emitted on transitions only. If Sources or++ the sync fails, nothing is invented: the failure is recorded, and the data ages into "stale".++- **Documents.** The sync pulls `category=trade` items by cursor. SEC EDGAR filings (the seeded++ `sec-xbrl-filings` source) are recognised by their `sec.gov/Archives/edgar/data/<CIK>/<accession>/`++ URL: CIK and accession come from the URL, the form type from the item summary (the feed's++ `<description>`) when it looks like one, the filer from the title. A filing without a date says++ "not stated by the source". Items that match no instrument are skipped and counted, never stored.++ A Sources removal hides the document (the row stays as the record).++- **Watchlists.** Private per person; plain forms and the API. Anyone else gets 404.++- **Alerts.** `threshold` rules fire on a crossing (armed → fired → re-armed when a newer++ observation no longer meets the condition; late observations never fire; unit and currency must++ match exactly, no conversion). `filing_type` and `new_document` fire once per newly seen document;++ item revisions never re-fire. Each delivery is one row and one `trade.alert.triggered` event++ (visibility `subject`, subject = the owner), in the same transaction as the triggering++ observation or document.++- **Context.** Editors write Markdown context citing documents and observations (`as_of` = the++ newest cited time, never the time of writing), publish, retract, and review. OpenVibe.AI output is++ a labelled AI-generated draft, never attributed to a person, that cannot be published before a++ person on the editor list approves it. Text that recommends buying, selling or holding, or gives++ price targets, is refused (`context.advice_refused`).++++### What it shows today++++Trade shows only what a source stated. OpenVibe.Sources' one trade source (`sec-xbrl-filings`) is++**disabled** until a person verifies its terms, and it carries filings, not prices. So a fresh++deployment has no instruments (editors add them), no documents until the source is enabled, and++**no numbers at all** until a market data source that states values exists — which is correct.++++### Routes (server-rendered, useful without JavaScript)++++| Route | What |++|---|---|++| `/` | instruments, recent documents, feeds |++| `/resolve?q=` | resolution; one match → 302 to the instrument |++| `/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 |++| `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed |++| `/feed.xml`, `/atom.xml`, `/feed.json` | recent documents across instruments |++| `/sources` | freshness of every source and of the sync |++| `/watchlists` | your watchlists, alert rules and deliveries (private, noindex, `private, no-store`) |++| `/editor`, `/editor/i/:symbol` | the editor (plain forms, editors only) |++| `/sitemap.xml` → `/sitemaps/instruments.xml` | indexable instrument pages only |++| `/robots.txt`, `/llms.txt` | crawler policy (private paths disallowed) and orientation |++| `/terms`, `/privacy`, `/dmca` | openvibe-shared legal pages |++| `/auth/*` | sign-in; the same session layer as Blog and Community |++| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness, release, metrics (loopback only) |++| `POST /internal/events` | signed OpenVibe.Events deliveries (`sources.*`), host-local |++++### Discoverability (roadmap §32)++++- Instrument pages go through the `openvibe-publishing/seo` gate. Financial information is a++ **sensitive** category: a page is `noindex` (`unreviewed_sensitive`) until a person-reviewed++ context is published, `noindex` when its newest monetary observation is older than++ `TRADE_PRICE_MAX_AGE_SEC` (`stale_price`), and `thin` below `TRADE_GATE_MIN_WORDS` of context.++- The sitemap lists indexable instrument pages only, `lastmod` = the context's real publication time.++- JSON-LD: `WebPage` (+ `Corporation` with `tickerSymbol` for equities) and breadcrumbs, from real++ fields only. No price, offer, rating or invented date.++- Every instrument page has a JSON twin with the same data (`/i/:symbol.json`).--## Planned surfaces++### Caching (nothing personal in shared caches)--- watchlists, sourced context timelines, alert rules via Notifications, cited summaries stating timestamp/scope++- `public, max-age=60` only for pages identical for everyone, served to anonymous viewers.++- `private, no-store` for everything personal (signed-in views, watchlists, alerts, editor), every++ error, every form response and the whole API. HTML varies on `Cookie` and `Authorization`.++- Feeds and sitemaps are public for 5 minutes and contain public documents and pages only.--## Data (authority tables / families)++### API `/api/v1` (problem+json errors)--- see above++| Capability | Routes |++|---|---|++| (public) | `GET /instruments`, `GET /instruments/:symbol`, `GET /instruments/:symbol/observations`, `GET /instruments/:symbol/documents`, `GET /sources` |++| `trade.instrument.resolve` | `GET /instruments/resolve?q=&kind=` |++| `trade.instrument.manage` | `POST /instruments`, `PATCH /instruments/:symbol`, `POST /instruments/:symbol/aliases` (people: editors) |++| `trade.observation.write` | `POST /observations` (services only) |++| `trade.context.read` | `GET /instruments/:symbol/context` (`?all=1` drafts), `GET /instruments/:symbol/context/input` (the AI workflow input) |++| `trade.context.propose` | `POST /instruments/:symbol/context` (`X-OV-Origin: ai` for AI drafts; editors) |++| people on the editor list | `POST /instruments/:symbol/context/revisions/:n/review` |++| `trade.watchlist.read` / `.create` / `.update` / `.delete` | `GET/POST /watchlists`, `GET/PATCH/DELETE /watchlists/:id`, `PUT/DELETE /watchlists/:id/items/:symbol` |++| `trade.alert.read` / `.create` / `.delete` | `GET /alerts`, `GET /alerts/deliveries`, `POST /alerts`, `DELETE /alerts/:id` |--## Capabilities and events++Service tokens use audience `openvibe.trade`, one capability per route; private data needs++`X-OV-Subject` (the person the service acts for). Browsers with a Network JWT are judged by ownership++and the editor list. The charter's `trade.watchlist.create|update`, `trade.instrument.resolve`,++`trade.alert.create|delete` and `trade.context.read` are all 3-segment ids here; `read`, `delete`,++`manage`, `observation.write` and `context.propose` are additions. Proposals:++[docs/capabilities-proposal/](docs/capabilities-proposal/) and++[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until the release,++grants for these ids are decided locally with the contracts library's matching rule++(`server/auth/capabilities.js`).--- `trade.watchlist.create|update`, `trade.instrument.resolve`, `trade.alert.create|delete`, `trade.context.read`++### Events (SDK outbox, same transaction as the change)--Events: ``trade.observation.created``, ``trade.alert.triggered``, ``trade.source.stale|recovered``++| Event | Visibility | Notes |++|---|---|---|++| `trade.observation.created` | public | instrument, metric, value, unit, currency, observed_at, retrieved_at, source |++| `trade.alert.triggered` | subject (the owner) | delivery id, rule, instrument, trigger with its timestamps and source; data only, no rendered text (ADR-020) |++| `trade.source.stale`, `trade.source.recovered` | public | on transitions only, with `stale_since` |++| `trade.index_document.upserted` / `.deleted` | internal | `search.index-document@1` documents of listable instrument pages, tombstones otherwise |++++Consumed: `sources.item.created|updated|removed` and `sources.fetch.failed` through the signed++webhook, as a wake-up for the cursor sync (the webhook is not durable truth; the sync is).++++### OpenVibe.AI seam (`trade.summarize_market_context`)++++1. The AI (or its caller) reads `GET /api/v1/instruments/:symbol/context/input` — the workflow input++ with numbered sources: each observation and document with `observed_at`/`published_at`,++ `retrieved_at` and its staleness.++2. The AI service posts the output to `POST /api/v1/instruments/:symbol/context` with++ `X-OV-Origin: ai`, `workflow { id, run_id }`, `input_sources` and `output` (the workflow's++ schema). Citations are indices into `input_sources` and must exist.++3. The revision is an AI-generated **draft**: labelled, noindex, not shown to readers until a person++ on the editor list approves it at `/editor/i/:symbol`.## Depends on--- source registry--- OpenVibe.AI--- Notifications--- Search--- OpenVibe.Events++- **Packages** (pinned by release tarball): `openvibe-contracts` v0.19.0, `openvibe-publishing`++ v0.2.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.3.0 (chrome, app icon,++ footer, legal, release, metrics, ready), `openvibe-sdk` v0.2.2 (events outbox and inbox, service++ tokens).++- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),++ JWKS, client-credentials tokens.++- **OpenVibe.Sources:** `sources.item.read` and `sources.source.read` (category `trade`).++- **OpenVibe.Events:** `events.event.publish`; a subscription for `sources.*` delivering to++ `http://127.0.0.1:4860/internal/events` (optional; the sync also runs on a timer).++- **OpenVibe.Search:** consumes `trade.index_document.*` (Search's `*.index_document.*` subscription;++ `trade` must be in `SEARCH_EVENT_OWNERS`).++- **OpenVibe.Network notifications:** a consumer of `trade.alert.triggered` (not built yet: until it++ exists, deliveries are recorded and visible at `/watchlists`, and the events wait in Events).++- **OpenVibe.AI:** optional, for drafts.--## Acceptance (must be true before "done")++### Grants the Network must hold--- every datum exposes observation/source timestamp--- alerts are idempotent per triggering observation--- stale/unavailable feeds are shown as such, never replaced with invented values--- no execution or custody endpoint exists++Each grant is `[client, capability, audience]`:--## Bootstrap / extraction source++- `[trade, events.event.publish, openvibe.events]`++- `[trade, sources.item.read, openvibe.sources]`++- `[trade, sources.source.read, openvibe.sources]`++- For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and++ `[ai, trade.context.propose, openvibe.trade]`++- For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]`++- For Network (or another first-party surface) to show a person's watchlists and alerts:++ `[network, trade.watchlist.read, openvibe.trade]`, `[network, trade.alert.read, openvibe.trade]` (optional)--No current implementation; Wave 17. ADR required before any commerce/marketplace work (backlog 134).++## Acceptance (automated: `npm test`)++++| Charter / roadmap / ADR-025 requirement | Test |++|---|---|++| **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` |++| 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` |++| **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` |++| **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` |++| **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` |++| AI output is a labelled draft, noindex until a person approves it; advice refused; editor forms; sitemap/Search follow publication. | `test/context.test.js` |++| Sources mapping (SEC filings), deterministic resolution, cursor replay, removals, signed webhook with inbox. | `test/sync.test.js` |++| 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` |++| Proposals valid against the released schemas and equal to what the code enforces and emits; every envelope valid. | `test/contracts.test.js` |## Launch rule--This repository does not make the product real, and the domain keeps its placeholder page on--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):----1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.----The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an--implemented service.++This repository alone does not make the product live. `openvibe.trade` keeps its placeholder on++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12++exists. Status against each point:++++1. **Runtime, health, readiness, observability:** done.++2. **Canonical identity and auth:** done (Network SSO, subjects, service tokens).++3. **SSR public routes useful without JS:** done.++4. **Persistence and end-to-end workflows:** done in tests; in production there is no market data++ source yet and the filings source is disabled in Sources (see "What it shows today").++5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,++ waiting on the release.++6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothing to++ migrate (no current implementation); no seed (editors add instruments); threat review below.++7. **Acceptance tests:** done.++++The launch release removes `openvibe.trade` from `OpenVibe.Sites/sites.json`, switches routing++(nginx vhost, DNS, TLS), flips the Network hub entry and registers maturity in the ecosystem++registry, atomically. A placeholder never counts as an implemented service.++++## Security and threat review++++- **Scope (ADR-025):** nothing moves or holds value. The route-inventory test is in CI; context that++ reads as advice is refused; the disclaimer is on every page.++- **Identity:** only verified Network JWTs (offline RS256 against JWKS) and service tokens for++ audience `openvibe.trade`; a bad service token is refused, never downgraded to anonymous; identity++ never comes from a body or query; `X-OV-*` headers are ignored for browsers.++- **Private data:** watchlists and alert rules are owner-only (404 otherwise), `private, no-store`,++ noindex, disallowed in robots, absent from sitemaps, feeds, Search documents and events (the++ alert event goes to its owner only, visibility `subject`).++- **CSRF:** SameSite=Lax session cookie plus an HMAC form token (`TRADE_FORM_SECRET`) on every form.++- **XSS:** all HTML through `openvibe-publishing/ssr` auto-escaping; context Markdown through its++ safe subset; external links `rel="nofollow noopener"`; CSP from helmet.++- **Honesty:** observation and retrieval times are required; values are stored exactly as stated;++ observations are immutable; documents never get invented dates; JSON-LD only from real fields;++ AI drafts are labelled and gated by a person's review.++- **SSRF:** Trade fetches only its configured Network and Sources hosts; it never fetches a URL from++ a source item (it links to it).++- **Webhook:** HMAC-verified raw body, inbox per event id, host-local only (nginx returns 404).++- **Abuse:** rate limits on `/auth`, private forms and `/api/v1`, in Express and in the nginx++ reference; per-person limits of 20 watchlists × 200 instruments and 100 alert rules.++- **Known gaps:** the advice filter is a phrase guard, not a guarantee (editors remain accountable);++ there is no market data source yet; Network does not consume `trade.alert.triggered` yet; Search++ documents exist only for instruments with reviewed context.++++## Development++++```bash++fnm exec --using=22.22.1 npm install++fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network++fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTH_CLIENT_SECRET to sign in)++```++++## Deploy (for the lead)++++1. **Code and config:** code at `/opt/openvibe.trade`, `npm ci --omit=dev` on Node 22.++ Create `/etc/openvibe/trade.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`,++ `TRADE_EDITORS`, `TRADE_FORM_SECRET`, `EVENTS_URL=http://127.0.0.1:4300`,++ `BASE_URL=https://openvibe.trade`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720` and, if the++ Events subscription is created, `TRADE_EVENTS_WEBHOOK_SECRET`.++2. **Network:** register (or seed) the OAuth client `trade` with redirect++ `https://openvibe.trade/auth/callback`, set its secret, add the grants above.++3. **Events (optional):** a subscription for consumer `trade`, topic `sources.*`, endpoint++ `http://127.0.0.1:4860/internal/events`, secret = `TRADE_EVENTS_WEBHOOK_SECRET`.++4. **Search:** add `trade` to `SEARCH_EVENT_OWNERS` if it is not there.++5. **systemd:** install `deploy/systemd/openvibe-trade.service` (port 4860, `StateDirectory=openvibe-trade`).++6. **nginx:** install `deploy/nginx/openvibe.trade.conf` (`/metrics` and `/internal/` never proxied).++7. **Contracts:** release the capability and manifest proposals; then CI's contracts check can drop++ `continue-on-error`.++8. **Data:** editors add instruments at `/editor`. Filings appear once `sec-xbrl-filings` is enabled++ in Sources (after a person verifies its terms); numbers appear only once a market data source++ exists and states them.++9. **Launch:** in the same release, remove `openvibe.trade` from OpenVibe.Sites and flip the Network++ hub entry (see the launch rule).---@@@ -1,7 +1,38 @@{"repository": "OpenVibers/OpenVibe.Trade",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W19","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, express 4, better-sqlite3 11",++ "port": 4860,++ "domain": "openvibe.trade",++ "scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",++ "packages": [++ "openvibe-contracts v0.19.0",++ "openvibe-publishing v0.2.0",++ "openvibe-shared v1.3.0",++ "openvibe-sdk v0.2.2"++ ],++ "capabilities": [++ "trade.instrument.resolve",++ "trade.instrument.manage",++ "trade.observation.write",++ "trade.context.read",++ "trade.context.propose",++ "trade.watchlist.read",++ "trade.watchlist.create",++ "trade.watchlist.update",++ "trade.watchlist.delete",++ "trade.alert.read",++ "trade.alert.create",++ "trade.alert.delete"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "marketDataFeeds": "none configured: the only seeded trade source in OpenVibe.Sources (SEC EDGAR XBRL filings RSS) is disabled there, and it carries filings, not prices; no observation exists until a source states one",++ "seed": "none (tests create their own instruments)",++ "deployed": false,++ "placeholderInSites": true,++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,111 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.trade — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.trade.conf — keep them in sync and reload nginx++## after changing it. The Node app (port 4860, systemd unit openvibe-trade) renders every page;++## nginx terminates TLS, redirects www → apex, rate-limits auth, private forms and API, and++## compresses text.++##++## No proxy_cache here on purpose: the app decides cacheability per response (Cache-Control++## public only for instrument pages and lists shown to anonymous visitors; private, no-store for++## watchlists, alerts, the editor, signed-in views and the API; Vary: Cookie, Authorization). A++## cache added in front must honour those headers — watchlists must never be stored by it.++##++## /internal/ (the signed OpenVibe.Events webhook) is not proxied: Events on this host delivers to++## http://127.0.0.1:4860/internal/events directly.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovtrade_api:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovtrade_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=ovtrade_write:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.trade www.openvibe.trade;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.trade$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.trade;++++ ssl_certificate /etc/letsencrypt/live/openvibe.trade/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.trade/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.trade$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.trade;++++ ssl_certificate /etc/letsencrypt/live/openvibe.trade/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.trade/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.trade.access.log;++ error_log /var/log/nginx/openvibe.trade.error.log;++++ # Forms are URL-encoded and small; JSON bodies are at most 512 KB. No uploads.++ client_max_body_size 1m;++++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints (strict) ──────────────++ location /auth/ {++ limit_req zone=ovtrade_auth burst=10 nodelay;++ proxy_pass http://127.0.0.1:4860;++ }++++ # ── Private forms: watchlists, alerts, the editor ───────++ location ~ ^/(watchlists|alerts|editor)(/|$) {++ limit_req zone=ovtrade_write burst=20 nodelay;++ proxy_pass http://127.0.0.1:4860;++ }++++ # ── Signed Events webhook: host-local only ──────────────++ location /internal/ { return 404; }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=ovtrade_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4860;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4860/metrics) ──++ location = /metrics { return 404; }++++ # ── Pages, JSON twins, feeds, sitemaps, robots.txt, llms.txt, static assets ──++ location / {++ proxy_pass http://127.0.0.1:4860;++ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,32 @@++[Unit]++Description=OpenVibe.Trade — informational market context (no custody, no order execution)++# Network (sign-in, JWKS, service tokens), Sources (the trade category) and Events (the outbox++# relay) should be up first, but Trade degrades gracefully without them: pages and feeds serve what++# was recorded, marked stale as it ages; sign-in, sync and events wait (see /api/ready).++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.trade++EnvironmentFile=/etc/openvibe/trade.env++Environment=NODE_ENV=production++Environment=PORT=4860++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.trade/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=10++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Trade's own database lives in /var/lib/openvibe-trade; the code tree stays read-only.++StateDirectory=openvibe-trade++Environment=TRADE_DB_PATH=/var/lib/openvibe-trade/trade.db++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "trade.alert.create",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Create an alert rule for the person in X-OV-Subject: threshold (a crossing of a metric in a unit), filing_type or new_document. Deliveries are unique per (rule, triggering observation or document) and are sent as trade.alert.triggered (visibility subject); no email.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-write",++ "events": [++ "trade.alert.triggered"++ ],++ "implementedBy": [++ "POST /api/v1/alerts"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "trade.alert.delete",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Delete an alert rule of the person in X-OV-Subject.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-write",++ "events": [],++ "implementedBy": [++ "DELETE /api/v1/alerts/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "trade.alert.read",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Read the alert rules and recent deliveries of the person in X-OV-Subject.",++ "permissions": [++ "trade:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/alerts",++ "GET /api/v1/alerts/deliveries"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "trade.context.propose",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Deliver a context revision. With X-OV-Origin: ai it is OpenVibe.AI output (workflow trade.summarize_market_context and its run id required): authorship ai, never attributed to a person, stored as a draft that cannot be published before a person on the editor list approves it. Text that reads as a recommendation to trade is refused.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "trade-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/instruments/:symbol/context"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "trade.context.read",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Read an instrument's published context with its citations and disclosure; with ?all=1 the drafts awaiting review; and the numbered-source input for OpenVibe.AI's trade.summarize_market_context workflow.",++ "permissions": [++ "trade:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "trade-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/instruments/:symbol/context",++ "GET /api/v1/instruments/:symbol/context/input"++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "trade.instrument.manage",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Create and edit instruments (symbol, name, kind, exchange, CIK, currency, status) and add ticker, CIK or name aliases. People must also be on the Trade editor list.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "trade-write",++ "events": [++ "trade.index_document.upserted",++ "trade.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/instruments",++ "PATCH /api/v1/instruments/:symbol",++ "POST /api/v1/instruments/:symbol/aliases"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "trade.instrument.resolve",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Resolve a ticker, SEC CIK or exact company name to an instrument, deterministically (CIK, then ticker, then exact normalised name; several name matches are \"ambiguous\" and none is chosen). Browsers and anonymous readers need no capability.",++ "permissions": [++ "trade:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "trade-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/instruments/resolve"++ ]++}nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "id": "trade.observation.write",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Record a market observation a source stated: value exactly as stated, unit, currency, observed_at (required, never inferred), source key and reference, retrieved_at (required). Idempotent per (source_key, source_ref); a different value under the same reference is 409. Services only.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "trade-write",++ "events": [++ "trade.observation.created",++ "trade.alert.triggered",++ "trade.source.stale",++ "trade.source.recovered",++ "trade.index_document.upserted",++ "trade.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/observations"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "trade.watchlist.create",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Create a private watchlist for the person in X-OV-Subject.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/watchlists"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "trade.watchlist.delete",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Delete a watchlist of the person in X-OV-Subject.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-write",++ "events": [],++ "implementedBy": [++ "DELETE /api/v1/watchlists/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "trade.watchlist.read",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Read the watchlists of the person in X-OV-Subject (never anyone else's: another person's watchlist is 404).",++ "permissions": [++ "trade:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/watchlists",++ "GET /api/v1/watchlists/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "trade.watchlist.update",++ "version": "1.0",++ "owner": "trade",++ "status": "active",++ "visibility": "first-party",++ "description": "Rename a watchlist of the person in X-OV-Subject, or add and remove instruments on it.",++ "permissions": [++ "trade:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "trade-write",++ "events": [],++ "implementedBy": [++ "PATCH /api/v1/watchlists/:id",++ "PUT /api/v1/watchlists/:id/items/:symbol",++ "DELETE /api/v1/watchlists/:id/items/:symbol"++ ]++}nnew file mode 100644@@@ -0,0 +1,56 @@++# Open question: the historical marketplace branch++++**Status:** unresolved. Recorded, not implemented. Binding decision: [ADR-025 (Marketplace and++commerce scope)](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-025-marketplace-scope.md).++++## The two branches++++The design history of `openvibe.trade` has two branches. Both are kept here on purpose, so a later++summary cannot quietly drop one (roadmap §15.13 obligations, backlog item 134, anti-goal 29: "Do++not erase the unresolved Trade marketplace branch; decide it explicitly").++++1. **Informational product — built.** Instruments and aliases, market observations with their++ observation and source timestamps, filings and other source documents, private watchlists,++ alert rules, and reviewed context. This repository implements this branch only.++2. **Marketplace — not built, not decided.** The older branch treated Trade as a marketplace:++ listings between users, orders, and custody of what is exchanged. The roadmap also names a later,++ separate extension for an asset / mod / theme / plugin / service marketplace. Nothing of either++ exists in any OpenVibe repository.++++## What ADR-025 decided++++- OpenVibe.Trade is informational.++- Excluded: custody, order execution, escrow, listings between users, and personalised financial++ advice.++- The marketplace branch stays an **open question**. It is neither accepted nor rejected for all++ time: it is out of scope until a new ADR decides it.++++## What reopening it requires++++A new ADR, written before any code, that covers at least:++++- **Legal basis:** which jurisdictions, which licences or registrations (a marketplace for++ financial instruments is a regulated activity; one for digital goods has consumer-protection,++ tax and platform-liability duties), and who is the merchant of record.++- **Billing flows:** money moves only through OpenVibe.Billing (ADR-012). The ADR must say which++ journal accounts a sale touches, how a seller is paid, and how refunds and chargebacks unwind.++- **Economic classification (ADR-012):** what is traded, and its class. Game state never bridges to++ money, loyalty units are not transferable and cosmetics are not tradable unless a new ADR says++ otherwise.++- **Fraud, disputes and abuse:** listing moderation, dispute handling, escrow or not, seller++ verification, sanctions screening where money is involved.++- **Ownership:** which service would own it. It would not be this informational service by++ default: ADR-025 keeps Trade informational, and a marketplace may belong in a separate service++ with its own threat review.++++## How this repository keeps the question open (and closed to accidents)++++- No table, route, capability or event for orders, listings, carts, checkout, escrow, wallets or++ custody exists. `test/route-inventory.test.js` walks the live Express stack in CI and fails if a++ route path, handler name, capability id or event type carries that vocabulary.++- Context text that recommends buying, selling or holding, or gives price targets, is refused++ (`server/domain/context.js`).++- Every page carries the disclaimer "Information only — not investment advice; no trading here."++++If the marketplace question is ever decided in favour of building something, that decision lands as++an ADR in OpenVibe.Contracts first, and this document is updated to point at it.nnew file mode 100644@@@ -0,0 +1,48 @@++{++ "version": "0.1.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.19.0 <1.0.0"++ },++ "id": "trade",++ "name": "OpenVibe.Trade",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Trade",++ "domains": [++ "openvibe.trade"++ ],++ "publicOrigin": "https://openvibe.trade",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "trade.instrument.resolve",++ "trade.instrument.manage",++ "trade.observation.write",++ "trade.context.read",++ "trade.context.propose",++ "trade.watchlist.read",++ "trade.watchlist.create",++ "trade.watchlist.update",++ "trade.watchlist.delete",++ "trade.alert.read",++ "trade.alert.create",++ "trade.alert.delete"++ ],++ "eventsProduced": [++ "trade.observation.created",++ "trade.alert.triggered",++ "trade.source.stale",++ "trade.source.recovered",++ "trade.index_document.upserted",++ "trade.index_document.deleted"++ ],++ "eventsConsumed": [++ "sources.item.created",++ "sources.item.updated",++ "sources.item.removed",++ "sources.fetch.failed"++ ],++ "namespacesOwned": [++ "trade.*"++ ],++ "notes": "Port 4860. Informational only (ADR-025): no custody, order execution, escrow, listings between users or personalised advice. Owns trade_instruments, trade_instrument_aliases, trade_watchlists, trade_watchlist_items, trade_market_observations, trade_source_documents, trade_alert_rules, trade_alert_deliveries, trade_context_revisions in its own SQLite. Reads OpenVibe.Sources (sources.item.read, sources.source.read, category trade) by cursor and wakes on signed sources.* webhook deliveries; consumes OpenVibe.AI trade.summarize_market_context output as reviewed drafts; publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads trade.index_document.* from Events."++}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.trade — environment configuration (names only; never commit values)
+# Copy to .env for development. Production: /etc/openvibe/trade.env (0600).
+# Information only: there is no payment, brokerage or custody configuration, by design (ADR-025).
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4860
+HOST=127.0.0.1
+NODE_ENV=development
+# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it
+BASE_URL=http://localhost:4860
+# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
+TRUST_PROXY=2
+
+# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)
+TRADE_DB_PATH=./data/trade.db
+
+# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OAuth client `trade` (redirect https://openvibe.trade/auth/callback). The same client is Trade's
+# service principal svc:trade for client-credentials grants (see README, "Grants").
+OV_OAUTH_CLIENT_ID=trade
+OV_OAUTH_CLIENT_SECRET=
+# Production: https://openvibe.trade/auth/callback (default: BASE_URL + /auth/callback)
+#OV_OAUTH_REDIRECT_URI=
+# Cookies: Secure by default in production; set false for plain-http local development
+#COOKIE_SECURE=false
+# Signs the watchlist, alert and editor form tokens (CSRF). Unset: random per process.
+#TRADE_FORM_SECRET=
+
+# Editors (create instruments, write context, review AI drafts): Network subjects, comma-separated.
+# Network admins are editors too.
+TRADE_EDITORS=
+
+# OpenVibe.Sources — the trade category (filings, market data feeds), read by cursor with the
+# service token above (sources.item.read, sources.source.read). Off while the secret is unset.
+OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
+#TRADE_SYNC_INTERVAL_MS=60000
+#TRADE_SYNC_PAGE_LIMIT=200
+#TRADE_SYNC_MAX_PAGES=20
+
+# Freshness: the window for a source that reports none, and the age after which a monetary
+# observation makes an instrument page noindex (stale_price)
+#TRADE_DEFAULT_STALE_AFTER_SEC=10800
+#TRADE_PRICE_MAX_AGE_SEC=86400
+#TRADE_FRESHNESS_INTERVAL_MS=60000
+# Indexability: minimum words of reviewed context before an instrument page may be indexed
+#TRADE_GATE_MIN_WORDS=60
+
+# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+# Secret of the Events subscription (topic sources.*) that POSTs to /internal/events.
+# Unset: the webhook answers 404 and the sync runs on its timer only.
+#TRADE_EVENTS_WEBHOOK_SECRET=
+
+# Background work (Sources sync + freshness); off: TRADE_WORKER=off
+#TRADE_WORKER=on
+
+# Browser origins that may call /api/v1 with a Bearer Network JWT
+#API_CORS_ORIGINS=https://openvibe.network
+
+# release.json (openvibe-shared/release): the deployed commit when git is not available
+#RELEASE_COMMIT=
+#MIN_CLIENT_RELEASE=
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ # Includes test/route-inventory.test.js: the build fails if any route, handler, capability or
+ # event carries order/buy/sell/execution/custody/wallet/escrow/checkout/listing semantics (ADR-025).
+ - run: npm test
+ # Trade's service manifest and capabilities are proposals (docs/) until the lead releases them
+ # in openvibe-contracts; until then the check reports that the manifest is a placeholder.
+ - run: npx openvibe-contracts-check --service trade --src server
+ continue-on-error: true
@@ -2,74 +2,315 @@
@@ -2,74 +2,315 @@
> Informational watchlists, sourced market context and alerts. No custody, no order execution.
> Informational watchlists, sourced market context and alerts. No custody, no order execution.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.trade`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11.
+**Status:** alpha (roadmap Wave 19). The service runs and its tests pass. It is **not deployed**,
+`openvibe.trade` still shows its placeholder from OpenVibe.Sites, and its capabilities and service
+manifest are proposals that the next openvibe-contracts release has to include. No market data
+feed is configured yet (see "What it shows today").
+**Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade`
+**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11;
+roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope).
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
+> **Information only — not investment advice; no trading here.** This sentence is on every page.
+
## Purpose
## Purpose
-An informational product first: symbol/entity resolution, user watchlists, sourced market/filing/catalyst context, threshold/event alerts and cited AI summaries with visible timestamps. Brokerage, custody or execution are out of scope unless a separate regulated design is approved; the historical marketplace branch is recorded as an open ADR, not implemented here.
+An informational product: instruments and their aliases with deterministic resolution, market
+observations that always show when they were observed, when they were retrieved and which source
+stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert
+rules, and short context written by editors (or drafted by OpenVibe.AI and reviewed by a person).
+
+Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no
+listing between users and no personalised financial advice — not as a feature, not as an endpoint,
+not as a table. The historical marketplace branch is recorded as an open question in
+[docs/marketplace-open-question.md](docs/marketplace-open-question.md), not implemented.
## Owns
## Owns
-- `trade_instruments`, `trade_instrument_aliases`, `trade_watchlists`, `trade_watchlist_items`, `trade_market_observations`, `trade_source_documents`, `trade_alert_rules`, `trade_alert_deliveries`, `trade_context_revisions`
+The nine charter tables (roadmap §15.13) live in Trade's own SQLite (`TRADE_DB_PATH`):
+
+| Charter table | What it is |
+|---|---|
+| `trade_instruments` | Symbol (canonical, upper case), name, kind, exchange, SEC CIK, trading currency, status, and which context revision is published. |
+| `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). |
+| `trade_watchlists` | Private, one owner (`usr_…`). |
+| `trade_watchlist_items` | Instruments on a watchlist, with an optional note. |
+| `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)`. |
+| `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. |
+| `trade_alert_rules` | `threshold` (metric, above/below, threshold, unit, currency), `filing_type` (form types) or `new_document`, one owner. |
+| `trade_alert_deliveries` | One row per `(rule, trigger kind, trigger id)` under a UNIQUE key — the idempotency of alerts. |
+| `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. |
+
+Other tables in the same database:
+
+- Package companions: `trade_context_drafts`, `trade_context_revision_purges`,
+ `trade_context_reviews` (a person's review of an AI draft) and `trade_index_revisions` (the
+ Search document sequencer).
+- `trade_source_status`: freshness of each source (Sources' health plus Trade's own clock).
+- `trade_sync_state`: the Sources cursor, the last error and counters of what was skipped and why.
+- `event_outbox` (SDK outbox) and `idempotency_receipts` (SDK inbox for signed webhook deliveries).
## Does not own
## Does not own
-- order execution, custody, personalised financial advice (none)
-- discussion (Community)
+- **Money, custody, orders, escrow, listings, advice.** None exist here (ADR-025). Money belongs to
+ OpenVibe.Billing (ADR-012).
+- **Source items.** OpenVibe.Sources owns ingestion, robots, terms and the items; Trade keeps the
+ metadata it shows, keyed by the item id, and follows revisions and removals.
+- **Identity.** OpenVibe.Network: SSO, subjects, service principals.
+- **Notifications.** Network renders and delivers them from `trade.alert.triggered` (ADR-020).
+ Trade sends no email.
+- **Search.** OpenVibe.Search indexes what Trade sends through Events.
+- **AI generation.** OpenVibe.AI runs `trade.summarize_market_context`; Trade makes no provider calls.
+- **Discussion.** Community (not wired yet; no comments here).
+
+## What works
+
+- **Instruments and resolution.** Editors add instruments (form or API). Resolution is exact and
+ deterministic, in a fixed order: CIK (all digits, optionally `CIK…`) → ticker (symbol, then ticker
+ aliases) → exact normalised name (diacritics, case, punctuation, `&`, `/DE/` and trailing corporate
+ words normalised). Several instruments with the same name → `ambiguous`, candidates listed, none
+ chosen. No fuzzy or prefix matching. `/i/aapl` and a ticker alias answer 301 to `/i/AAPL`.
+- **Observations.** Recorded by a first-party feed (`POST /api/v1/observations`,
+ `trade.observation.write`) or from Sources items that state a value (`fields.metric`, `value`,
+ `unit`, `observed_at`, a symbol or CIK). `observed_at` and `retrieved_at` are required and never
+ defaulted; a replay is `200 created:false`; the same reference with a different value is `409`.
+ **No observation → no number:** the page says "No observations have been recorded for X, so no
+ number is shown."
+- **Freshness.** Per source: fresh only while the last successful fetch is younger than the source's
+ window (Sources' `stale_after_sec`, else `TRADE_DEFAULT_STALE_AFTER_SEC`), computed with Trade's
+ clock. A stale value stays visible with its timestamps and a **"stale since <time>"** badge; a
+ source Trade has never heard of is "freshness unknown". An observation can also carry its own
+ `max_age_sec`. `trade.source.stale` / `.recovered` are emitted on transitions only. If Sources or
+ the sync fails, nothing is invented: the failure is recorded, and the data ages into "stale".
+- **Documents.** The sync pulls `category=trade` items by cursor. SEC EDGAR filings (the seeded
+ `sec-xbrl-filings` source) are recognised by their `sec.gov/Archives/edgar/data/<CIK>/<accession>/`
+ URL: CIK and accession come from the URL, the form type from the item summary (the feed's
+ `<description>`) when it looks like one, the filer from the title. A filing without a date says
+ "not stated by the source". Items that match no instrument are skipped and counted, never stored.
+ A Sources removal hides the document (the row stays as the record).
+- **Watchlists.** Private per person; plain forms and the API. Anyone else gets 404.
+- **Alerts.** `threshold` rules fire on a crossing (armed → fired → re-armed when a newer
+ observation no longer meets the condition; late observations never fire; unit and currency must
+ match exactly, no conversion). `filing_type` and `new_document` fire once per newly seen document;
+ item revisions never re-fire. Each delivery is one row and one `trade.alert.triggered` event
+ (visibility `subject`, subject = the owner), in the same transaction as the triggering
+ observation or document.
+- **Context.** Editors write Markdown context citing documents and observations (`as_of` = the
+ newest cited time, never the time of writing), publish, retract, and review. OpenVibe.AI output is
+ a labelled AI-generated draft, never attributed to a person, that cannot be published before a
+ person on the editor list approves it. Text that recommends buying, selling or holding, or gives
+ price targets, is refused (`context.advice_refused`).
+
+### What it shows today
+
+Trade shows only what a source stated. OpenVibe.Sources' one trade source (`sec-xbrl-filings`) is
+**disabled** until a person verifies its terms, and it carries filings, not prices. So a fresh
+deployment has no instruments (editors add them), no documents until the source is enabled, and
+**no numbers at all** until a market data source that states values exists — which is correct.
+
+### Routes (server-rendered, useful without JavaScript)
+
+| Route | What |
+|---|---|
+| `/` | instruments, recent documents, feeds |
+| `/resolve?q=` | resolution; one match → 302 to the instrument |
+| `/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 |
+| `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed |
+| `/feed.xml`, `/atom.xml`, `/feed.json` | recent documents across instruments |
+| `/sources` | freshness of every source and of the sync |
+| `/watchlists` | your watchlists, alert rules and deliveries (private, noindex, `private, no-store`) |
+| `/editor`, `/editor/i/:symbol` | the editor (plain forms, editors only) |
+| `/sitemap.xml` → `/sitemaps/instruments.xml` | indexable instrument pages only |
+| `/robots.txt`, `/llms.txt` | crawler policy (private paths disallowed) and orientation |
+| `/terms`, `/privacy`, `/dmca` | openvibe-shared legal pages |
+| `/auth/*` | sign-in; the same session layer as Blog and Community |
+| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness, release, metrics (loopback only) |
+| `POST /internal/events` | signed OpenVibe.Events deliveries (`sources.*`), host-local |
+
+### Discoverability (roadmap §32)
+
+- Instrument pages go through the `openvibe-publishing/seo` gate. Financial information is a
+ **sensitive** category: a page is `noindex` (`unreviewed_sensitive`) until a person-reviewed
+ context is published, `noindex` when its newest monetary observation is older than
+ `TRADE_PRICE_MAX_AGE_SEC` (`stale_price`), and `thin` below `TRADE_GATE_MIN_WORDS` of context.
+- The sitemap lists indexable instrument pages only, `lastmod` = the context's real publication time.
+- JSON-LD: `WebPage` (+ `Corporation` with `tickerSymbol` for equities) and breadcrumbs, from real
+ fields only. No price, offer, rating or invented date.
+- Every instrument page has a JSON twin with the same data (`/i/:symbol.json`).
-## Planned surfaces
+### Caching (nothing personal in shared caches)
-- watchlists, sourced context timelines, alert rules via Notifications, cited summaries stating timestamp/scope
+- `public, max-age=60` only for pages identical for everyone, served to anonymous viewers.
+- `private, no-store` for everything personal (signed-in views, watchlists, alerts, editor), every
+ error, every form response and the whole API. HTML varies on `Cookie` and `Authorization`.
+- Feeds and sitemaps are public for 5 minutes and contain public documents and pages only.
-## Data (authority tables / families)
+### API `/api/v1` (problem+json errors)
-- see above
+| Capability | Routes |
+|---|---|
+| (public) | `GET /instruments`, `GET /instruments/:symbol`, `GET /instruments/:symbol/observations`, `GET /instruments/:symbol/documents`, `GET /sources` |
+| `trade.instrument.resolve` | `GET /instruments/resolve?q=&kind=` |
+| `trade.instrument.manage` | `POST /instruments`, `PATCH /instruments/:symbol`, `POST /instruments/:symbol/aliases` (people: editors) |
+| `trade.observation.write` | `POST /observations` (services only) |
+| `trade.context.read` | `GET /instruments/:symbol/context` (`?all=1` drafts), `GET /instruments/:symbol/context/input` (the AI workflow input) |
+| `trade.context.propose` | `POST /instruments/:symbol/context` (`X-OV-Origin: ai` for AI drafts; editors) |
+| people on the editor list | `POST /instruments/:symbol/context/revisions/:n/review` |
+| `trade.watchlist.read` / `.create` / `.update` / `.delete` | `GET/POST /watchlists`, `GET/PATCH/DELETE /watchlists/:id`, `PUT/DELETE /watchlists/:id/items/:symbol` |
+| `trade.alert.read` / `.create` / `.delete` | `GET /alerts`, `GET /alerts/deliveries`, `POST /alerts`, `DELETE /alerts/:id` |
-## Capabilities and events
+Service tokens use audience `openvibe.trade`, one capability per route; private data needs
+`X-OV-Subject` (the person the service acts for). Browsers with a Network JWT are judged by ownership
+and the editor list. The charter's `trade.watchlist.create|update`, `trade.instrument.resolve`,
+`trade.alert.create|delete` and `trade.context.read` are all 3-segment ids here; `read`, `delete`,
+`manage`, `observation.write` and `context.propose` are additions. Proposals:
+[docs/capabilities-proposal/](docs/capabilities-proposal/) and
+[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until the release,
+grants for these ids are decided locally with the contracts library's matching rule
+(`server/auth/capabilities.js`).
-- `trade.watchlist.create|update`, `trade.instrument.resolve`, `trade.alert.create|delete`, `trade.context.read`
+### Events (SDK outbox, same transaction as the change)
-Events: ``trade.observation.created``, ``trade.alert.triggered``, ``trade.source.stale|recovered``
+| Event | Visibility | Notes |
+|---|---|---|
+| `trade.observation.created` | public | instrument, metric, value, unit, currency, observed_at, retrieved_at, source |
+| `trade.alert.triggered` | subject (the owner) | delivery id, rule, instrument, trigger with its timestamps and source; data only, no rendered text (ADR-020) |
+| `trade.source.stale`, `trade.source.recovered` | public | on transitions only, with `stale_since` |
+| `trade.index_document.upserted` / `.deleted` | internal | `search.index-document@1` documents of listable instrument pages, tombstones otherwise |
+
+Consumed: `sources.item.created|updated|removed` and `sources.fetch.failed` through the signed
+webhook, as a wake-up for the cursor sync (the webhook is not durable truth; the sync is).
+
+### OpenVibe.AI seam (`trade.summarize_market_context`)
+
+1. The AI (or its caller) reads `GET /api/v1/instruments/:symbol/context/input` — the workflow input
+ with numbered sources: each observation and document with `observed_at`/`published_at`,
+ `retrieved_at` and its staleness.
+2. The AI service posts the output to `POST /api/v1/instruments/:symbol/context` with
+ `X-OV-Origin: ai`, `workflow { id, run_id }`, `input_sources` and `output` (the workflow's
+ schema). Citations are indices into `input_sources` and must exist.
+3. The revision is an AI-generated **draft**: labelled, noindex, not shown to readers until a person
+ on the editor list approves it at `/editor/i/:symbol`.
## Depends on
## Depends on
-- source registry
-- OpenVibe.AI
-- Notifications
-- Search
-- OpenVibe.Events
+- **Packages** (pinned by release tarball): `openvibe-contracts` v0.19.0, `openvibe-publishing`
+ v0.2.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.3.0 (chrome, app icon,
+ footer, legal, release, metrics, ready), `openvibe-sdk` v0.2.2 (events outbox and inbox, service
+ tokens).
+- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),
+ JWKS, client-credentials tokens.
+- **OpenVibe.Sources:** `sources.item.read` and `sources.source.read` (category `trade`).
+- **OpenVibe.Events:** `events.event.publish`; a subscription for `sources.*` delivering to
+ `http://127.0.0.1:4860/internal/events` (optional; the sync also runs on a timer).
+- **OpenVibe.Search:** consumes `trade.index_document.*` (Search's `*.index_document.*` subscription;
+ `trade` must be in `SEARCH_EVENT_OWNERS`).
+- **OpenVibe.Network notifications:** a consumer of `trade.alert.triggered` (not built yet: until it
+ exists, deliveries are recorded and visible at `/watchlists`, and the events wait in Events).
+- **OpenVibe.AI:** optional, for drafts.
-## Acceptance (must be true before "done")
+### Grants the Network must hold
-- every datum exposes observation/source timestamp
-- alerts are idempotent per triggering observation
-- stale/unavailable feeds are shown as such, never replaced with invented values
-- no execution or custody endpoint exists
+Each grant is `[client, capability, audience]`:
-## Bootstrap / extraction source
+- `[trade, events.event.publish, openvibe.events]`
+- `[trade, sources.item.read, openvibe.sources]`
+- `[trade, sources.source.read, openvibe.sources]`
+- For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and
+ `[ai, trade.context.propose, openvibe.trade]`
+- For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]`
+- For Network (or another first-party surface) to show a person's watchlists and alerts:
+ `[network, trade.watchlist.read, openvibe.trade]`, `[network, trade.alert.read, openvibe.trade]` (optional)
-No current implementation; Wave 17. ADR required before any commerce/marketplace work (backlog 134).
+## Acceptance (automated: `npm test`)
+
+| Charter / roadmap / ADR-025 requirement | Test |
+|---|---|
+| **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` |
+| 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` |
+| **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` |
+| **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` |
+| **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` |
+| AI output is a labelled draft, noindex until a person approves it; advice refused; editor forms; sitemap/Search follow publication. | `test/context.test.js` |
+| Sources mapping (SEC filings), deterministic resolution, cursor replay, removals, signed webhook with inbox. | `test/sync.test.js` |
+| 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` |
+| Proposals valid against the released schemas and equal to what the code enforces and emits; every envelope valid. | `test/contracts.test.js` |
## Launch rule
## Launch rule
-This repository does not make the product real, and the domain keeps its placeholder page on
-[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
-
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
-
-The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
-registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
-implemented service.
+This repository alone does not make the product live. `openvibe.trade` keeps its placeholder on
+[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12
+exists. Status against each point:
+
+1. **Runtime, health, readiness, observability:** done.
+2. **Canonical identity and auth:** done (Network SSO, subjects, service tokens).
+3. **SSR public routes useful without JS:** done.
+4. **Persistence and end-to-end workflows:** done in tests; in production there is no market data
+ source yet and the filings source is disabled in Sources (see "What it shows today").
+5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`,
+ waiting on the release.
+6. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. Nothing to
+ migrate (no current implementation); no seed (editors add instruments); threat review below.
+7. **Acceptance tests:** done.
+
+The launch release removes `openvibe.trade` from `OpenVibe.Sites/sites.json`, switches routing
+(nginx vhost, DNS, TLS), flips the Network hub entry and registers maturity in the ecosystem
+registry, atomically. A placeholder never counts as an implemented service.
+
+## Security and threat review
+
+- **Scope (ADR-025):** nothing moves or holds value. The route-inventory test is in CI; context that
+ reads as advice is refused; the disclaimer is on every page.
+- **Identity:** only verified Network JWTs (offline RS256 against JWKS) and service tokens for
+ audience `openvibe.trade`; a bad service token is refused, never downgraded to anonymous; identity
+ never comes from a body or query; `X-OV-*` headers are ignored for browsers.
+- **Private data:** watchlists and alert rules are owner-only (404 otherwise), `private, no-store`,
+ noindex, disallowed in robots, absent from sitemaps, feeds, Search documents and events (the
+ alert event goes to its owner only, visibility `subject`).
+- **CSRF:** SameSite=Lax session cookie plus an HMAC form token (`TRADE_FORM_SECRET`) on every form.
+- **XSS:** all HTML through `openvibe-publishing/ssr` auto-escaping; context Markdown through its
+ safe subset; external links `rel="nofollow noopener"`; CSP from helmet.
+- **Honesty:** observation and retrieval times are required; values are stored exactly as stated;
+ observations are immutable; documents never get invented dates; JSON-LD only from real fields;
+ AI drafts are labelled and gated by a person's review.
+- **SSRF:** Trade fetches only its configured Network and Sources hosts; it never fetches a URL from
+ a source item (it links to it).
+- **Webhook:** HMAC-verified raw body, inbox per event id, host-local only (nginx returns 404).
+- **Abuse:** rate limits on `/auth`, private forms and `/api/v1`, in Express and in the nginx
+ reference; per-person limits of 20 watchlists × 200 instruments and 100 alert rules.
+- **Known gaps:** the advice filter is a phrase guard, not a guarantee (editors remain accountable);
+ there is no market data source yet; Network does not consume `trade.alert.triggered` yet; Search
+ documents exist only for instruments with reviewed context.
+
+## Development
+
+```bash
+fnm exec --using=22.22.1 npm install
+fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
+fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTH_CLIENT_SECRET to sign in)
+```
+
+## Deploy (for the lead)
+
+1. **Code and config:** code at `/opt/openvibe.trade`, `npm ci --omit=dev` on Node 22.
+ Create `/etc/openvibe/trade.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`,
+ `TRADE_EDITORS`, `TRADE_FORM_SECRET`, `EVENTS_URL=http://127.0.0.1:4300`,
+ `BASE_URL=https://openvibe.trade`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720` and, if the
+ Events subscription is created, `TRADE_EVENTS_WEBHOOK_SECRET`.
+2. **Network:** register (or seed) the OAuth client `trade` with redirect
+ `https://openvibe.trade/auth/callback`, set its secret, add the grants above.
+3. **Events (optional):** a subscription for consumer `trade`, topic `sources.*`, endpoint
+ `http://127.0.0.1:4860/internal/events`, secret = `TRADE_EVENTS_WEBHOOK_SECRET`.
+4. **Search:** add `trade` to `SEARCH_EVENT_OWNERS` if it is not there.
+5. **systemd:** install `deploy/systemd/openvibe-trade.service` (port 4860, `StateDirectory=openvibe-trade`).
+6. **nginx:** install `deploy/nginx/openvibe.trade.conf` (`/metrics` and `/internal/` never proxied).
+7. **Contracts:** release the capability and manifest proposals; then CI's contracts check can drop
+ `continue-on-error`.
+8. **Data:** editors add instruments at `/editor`. Filings appear once `sec-xbrl-filings` is enabled
+ in Sources (after a person verifies its terms); numbers appear only once a market data source
+ exists and states them.
+9. **Launch:** in the same release, remove `openvibe.trade` from OpenVibe.Sites and flip the Network
+ hub entry (see the launch rule).
---
---
@@ -1,7 +1,38 @@
@@ -1,7 +1,38 @@
{
{
"repository": "OpenVibers/OpenVibe.Trade",
"repository": "OpenVibers/OpenVibe.Trade",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W19",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, express 4, better-sqlite3 11",
+ "port": 4860,
+ "domain": "openvibe.trade",
+ "scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",
+ "packages": [
+ "openvibe-contracts v0.19.0",
+ "openvibe-publishing v0.2.0",
+ "openvibe-shared v1.3.0",
+ "openvibe-sdk v0.2.2"
+ ],
+ "capabilities": [
+ "trade.instrument.resolve",
+ "trade.instrument.manage",
+ "trade.observation.write",
+ "trade.context.read",
+ "trade.context.propose",
+ "trade.watchlist.read",
+ "trade.watchlist.create",
+ "trade.watchlist.update",
+ "trade.watchlist.delete",
+ "trade.alert.read",
+ "trade.alert.create",
+ "trade.alert.delete"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "marketDataFeeds": "none configured: the only seeded trade source in OpenVibe.Sources (SEC EDGAR XBRL filings RSS) is disabled there, and it carries filings, not prices; no observation exists until a source states one",
+ "seed": "none (tests create their own instruments)",
+ "deployed": false,
+ "placeholderInSites": true,
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.trade — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.trade.conf — keep them in sync and reload nginx
+## after changing it. The Node app (port 4860, systemd unit openvibe-trade) renders every page;
+## nginx terminates TLS, redirects www → apex, rate-limits auth, private forms and API, and
+## compresses text.
+##
+## No proxy_cache here on purpose: the app decides cacheability per response (Cache-Control
+## public only for instrument pages and lists shown to anonymous visitors; private, no-store for
+## watchlists, alerts, the editor, signed-in views and the API; Vary: Cookie, Authorization). A
+## cache added in front must honour those headers — watchlists must never be stored by it.
+##
+## /internal/ (the signed OpenVibe.Events webhook) is not proxied: Events on this host delivers to
+## http://127.0.0.1:4860/internal/events directly.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovtrade_api:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovtrade_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=ovtrade_write:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.trade www.openvibe.trade;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.trade$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.trade;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.trade/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.trade/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.trade$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.trade;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.trade/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.trade/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.trade.access.log;
+ error_log /var/log/nginx/openvibe.trade.error.log;
+
+ # Forms are URL-encoded and small; JSON bodies are at most 512 KB. No uploads.
+ client_max_body_size 1m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints (strict) ──────────────
+ location /auth/ {
+ limit_req zone=ovtrade_auth burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4860;
+ }
+
+ # ── Private forms: watchlists, alerts, the editor ───────
+ location ~ ^/(watchlists|alerts|editor)(/|$) {
+ limit_req zone=ovtrade_write burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4860;
+ }
+
+ # ── Signed Events webhook: host-local only ──────────────
+ location /internal/ { return 404; }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=ovtrade_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4860;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4860/metrics) ──
+ location = /metrics { return 404; }
+
+ # ── Pages, JSON twins, feeds, sitemaps, robots.txt, llms.txt, static assets ──
+ location / {
+ proxy_pass http://127.0.0.1:4860;
+ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+[Unit]
+Description=OpenVibe.Trade — informational market context (no custody, no order execution)
+# Network (sign-in, JWKS, service tokens), Sources (the trade category) and Events (the outbox
+# relay) should be up first, but Trade degrades gracefully without them: pages and feeds serve what
+# was recorded, marked stale as it ages; sign-in, sync and events wait (see /api/ready).
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.trade
+EnvironmentFile=/etc/openvibe/trade.env
+Environment=NODE_ENV=production
+Environment=PORT=4860
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.trade/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Trade's own database lives in /var/lib/openvibe-trade; the code tree stays read-only.
+StateDirectory=openvibe-trade
+Environment=TRADE_DB_PATH=/var/lib/openvibe-trade/trade.db
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "trade.alert.create",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create an alert rule for the person in X-OV-Subject: threshold (a crossing of a metric in a unit), filing_type or new_document. Deliveries are unique per (rule, triggering observation or document) and are sent as trade.alert.triggered (visibility subject); no email.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-write",
+ "events": [
+ "trade.alert.triggered"
+ ],
+ "implementedBy": [
+ "POST /api/v1/alerts"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "trade.alert.delete",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Delete an alert rule of the person in X-OV-Subject.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-write",
+ "events": [],
+ "implementedBy": [
+ "DELETE /api/v1/alerts/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "trade.alert.read",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read the alert rules and recent deliveries of the person in X-OV-Subject.",
+ "permissions": [
+ "trade:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/alerts",
+ "GET /api/v1/alerts/deliveries"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "trade.context.propose",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Deliver a context revision. With X-OV-Origin: ai it is OpenVibe.AI output (workflow trade.summarize_market_context and its run id required): authorship ai, never attributed to a person, stored as a draft that cannot be published before a person on the editor list approves it. Text that reads as a recommendation to trade is refused.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "trade-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/instruments/:symbol/context"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "trade.context.read",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read an instrument's published context with its citations and disclosure; with ?all=1 the drafts awaiting review; and the numbered-source input for OpenVibe.AI's trade.summarize_market_context workflow.",
+ "permissions": [
+ "trade:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "trade-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/instruments/:symbol/context",
+ "GET /api/v1/instruments/:symbol/context/input"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "trade.instrument.manage",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create and edit instruments (symbol, name, kind, exchange, CIK, currency, status) and add ticker, CIK or name aliases. People must also be on the Trade editor list.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "trade-write",
+ "events": [
+ "trade.index_document.upserted",
+ "trade.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/instruments",
+ "PATCH /api/v1/instruments/:symbol",
+ "POST /api/v1/instruments/:symbol/aliases"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "trade.instrument.resolve",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Resolve a ticker, SEC CIK or exact company name to an instrument, deterministically (CIK, then ticker, then exact normalised name; several name matches are \"ambiguous\" and none is chosen). Browsers and anonymous readers need no capability.",
+ "permissions": [
+ "trade:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "trade-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/instruments/resolve"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "id": "trade.observation.write",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Record a market observation a source stated: value exactly as stated, unit, currency, observed_at (required, never inferred), source key and reference, retrieved_at (required). Idempotent per (source_key, source_ref); a different value under the same reference is 409. Services only.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "trade-write",
+ "events": [
+ "trade.observation.created",
+ "trade.alert.triggered",
+ "trade.source.stale",
+ "trade.source.recovered",
+ "trade.index_document.upserted",
+ "trade.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/observations"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "trade.watchlist.create",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create a private watchlist for the person in X-OV-Subject.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/watchlists"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "trade.watchlist.delete",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Delete a watchlist of the person in X-OV-Subject.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-write",
+ "events": [],
+ "implementedBy": [
+ "DELETE /api/v1/watchlists/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "trade.watchlist.read",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read the watchlists of the person in X-OV-Subject (never anyone else's: another person's watchlist is 404).",
+ "permissions": [
+ "trade:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/watchlists",
+ "GET /api/v1/watchlists/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "trade.watchlist.update",
+ "version": "1.0",
+ "owner": "trade",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Rename a watchlist of the person in X-OV-Subject, or add and remove instruments on it.",
+ "permissions": [
+ "trade:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "trade-write",
+ "events": [],
+ "implementedBy": [
+ "PATCH /api/v1/watchlists/:id",
+ "PUT /api/v1/watchlists/:id/items/:symbol",
+ "DELETE /api/v1/watchlists/:id/items/:symbol"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+# Open question: the historical marketplace branch
+
+**Status:** unresolved. Recorded, not implemented. Binding decision: [ADR-025 (Marketplace and
+commerce scope)](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-025-marketplace-scope.md).
+
+## The two branches
+
+The design history of `openvibe.trade` has two branches. Both are kept here on purpose, so a later
+summary cannot quietly drop one (roadmap §15.13 obligations, backlog item 134, anti-goal 29: "Do
+not erase the unresolved Trade marketplace branch; decide it explicitly").
+
+1. **Informational product — built.** Instruments and aliases, market observations with their
+ observation and source timestamps, filings and other source documents, private watchlists,
+ alert rules, and reviewed context. This repository implements this branch only.
+2. **Marketplace — not built, not decided.** The older branch treated Trade as a marketplace:
+ listings between users, orders, and custody of what is exchanged. The roadmap also names a later,
+ separate extension for an asset / mod / theme / plugin / service marketplace. Nothing of either
+ exists in any OpenVibe repository.
+
+## What ADR-025 decided
+
+- OpenVibe.Trade is informational.
+- Excluded: custody, order execution, escrow, listings between users, and personalised financial
+ advice.
+- The marketplace branch stays an **open question**. It is neither accepted nor rejected for all
+ time: it is out of scope until a new ADR decides it.
+
+## What reopening it requires
+
+A new ADR, written before any code, that covers at least:
+
+- **Legal basis:** which jurisdictions, which licences or registrations (a marketplace for
+ financial instruments is a regulated activity; one for digital goods has consumer-protection,
+ tax and platform-liability duties), and who is the merchant of record.
+- **Billing flows:** money moves only through OpenVibe.Billing (ADR-012). The ADR must say which
+ journal accounts a sale touches, how a seller is paid, and how refunds and chargebacks unwind.
+- **Economic classification (ADR-012):** what is traded, and its class. Game state never bridges to
+ money, loyalty units are not transferable and cosmetics are not tradable unless a new ADR says
+ otherwise.
+- **Fraud, disputes and abuse:** listing moderation, dispute handling, escrow or not, seller
+ verification, sanctions screening where money is involved.
+- **Ownership:** which service would own it. It would not be this informational service by
+ default: ADR-025 keeps Trade informational, and a marketplace may belong in a separate service
+ with its own threat review.
+
+## How this repository keeps the question open (and closed to accidents)
+
+- No table, route, capability or event for orders, listings, carts, checkout, escrow, wallets or
+ custody exists. `test/route-inventory.test.js` walks the live Express stack in CI and fails if a
+ route path, handler name, capability id or event type carries that vocabulary.
+- Context text that recommends buying, selling or holding, or gives price targets, is refused
+ (`server/domain/context.js`).
+- Every page carries the disclaimer "Information only — not investment advice; no trading here."
+
+If the marketplace question is ever decided in favour of building something, that decision lands as
+an ADR in OpenVibe.Contracts first, and this document is updated to point at it.
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+{
+ "version": "0.1.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.19.0 <1.0.0"
+ },
+ "id": "trade",
+ "name": "OpenVibe.Trade",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Trade",
+ "domains": [
+ "openvibe.trade"
+ ],
+ "publicOrigin": "https://openvibe.trade",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "trade.instrument.resolve",
+ "trade.instrument.manage",
+ "trade.observation.write",
+ "trade.context.read",
+ "trade.context.propose",
+ "trade.watchlist.read",
+ "trade.watchlist.create",
+ "trade.watchlist.update",
+ "trade.watchlist.delete",
+ "trade.alert.read",
+ "trade.alert.create",
+ "trade.alert.delete"
+ ],
+ "eventsProduced": [
+ "trade.observation.created",
+ "trade.alert.triggered",
+ "trade.source.stale",
+ "trade.source.recovered",
+ "trade.index_document.upserted",
+ "trade.index_document.deleted"
+ ],
+ "eventsConsumed": [
+ "sources.item.created",
+ "sources.item.updated",
+ "sources.item.removed",
+ "sources.fetch.failed"
+ ],
+ "namespacesOwned": [
+ "trade.*"
+ ],
+ "notes": "Port 4860. Informational only (ADR-025): no custody, order execution, escrow, listings between users or personalised advice. Owns trade_instruments, trade_instrument_aliases, trade_watchlists, trade_watchlist_items, trade_market_observations, trade_source_documents, trade_alert_rules, trade_alert_deliveries, trade_context_revisions in its own SQLite. Reads OpenVibe.Sources (sources.item.read, sources.source.read, category trade) by cursor and wakes on signed sources.* webhook deliveries; consumes OpenVibe.AI trade.summarize_market_context output as reviewed drafts; publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads trade.index_document.* from Events."
+}