Stream Contract v0.2 (Planned)
This is the API the Rust desktop client speaks. It is a completely different contract from the Laravel /api/v1 that the mobile app uses, and no deployed server implements any part of it. The source of record is demostatics-pc_application/docs/API_CONTRACT.md, cross-checked here against the Rust code that implements it.
How to read the status labels on this page
Section titled “How to read the status labels on this page”The types and the mock are real, runnable Rust. Nothing on the far side of the wire is.
| Label used here | Means |
|---|---|
| Shipped | Implemented in the desktop repo and exercised against the in-repo mock server |
| Partial | Some of it exists in code, with named gaps |
| Planned | Written into API_CONTRACT.md, no code |
| Not built | No implementation anywhere, in either repo |
A production Demostatics server for this contract is Not built, without exception. Read every “Shipped” below as “shipped in the client and in the mock”.
The model: thick client
Section titled “The model: thick client”The contract’s opening claim is that the client is a thick client. The server supplies data, authentication and entitlements; the client does the UI and all statistical analysis locally.
The server is the authority on entitlements. The client never decides what a subscription unlocks — it receives an Entitlements block in the Welcome frame and gates its UI from that. ds-core::identity::Entitlements::can is the only gate the client owns, and it answers from what the server sent.
That division is Shipped in code: crates/ds-core/src/identity.rs has no notion of which tier grants which feature. The tier-to-feature mapping lives entirely on the server side — in the mock, in the dev_account table in crates/ds-auth/src/provider.rs.
Auth plane
Section titled “Auth plane”Two REST endpoints over TLS, in crates/ds-auth/src/http.rs (client) and examples/mock-stream-server/src/http.rs (mock server).
POST /v1/auth/login body: { "email": "...", "password": "..." } 200: { "token": "<JWT>", "account": "...", "expires_at": <epoch_ms> } 401: { "error": "invalid_credentials" }
POST /v1/auth/refresh header: Authorization: Bearer <token> 200: { "token": "<JWT>", "expires_at": <epoch_ms> }| Piece | Status | Note |
|---|---|---|
POST /v1/auth/login | Shipped (client + mock) | HttpAuthProvider::login; mock route in http.rs |
POST /v1/auth/refresh | Shipped (client + mock) | Bearer in, new token out |
| Signed JWT | Not built | See below — no signing, no verification, anywhere |
expires_at on either response | Not built | Specified in the contract; neither emitted nor parsed |
| Token in the OS keychain | Shipped | ds-auth::SecretStore over keyring |
Documented token claims
Section titled “Documented token claims”API_CONTRACT.md specifies a signed JWT carrying these claims:
{ "sub": "pro@demostatics.com", "tier": "pro", "features": ["dashboards", "risk-views", "premium-risk", "reports", "ai-assistant"], "iat": <epoch_ms>, "exp": <epoch_ms> }The stated rationale: carrying claims lets the client gate its UI immediately and reconnect without re-login, while the stream server still re-validates the signature and exp and remains the authority.
What the code actually does
Section titled “What the code actually does”This section is the answer to “is there any signature verification anywhere in the repo?” The answer is no.
There is no JWT library in the workspace. A search across crates/, bin/ and examples/ for jsonwebtoken, hmac or ed25519 returns zero hits — no dependency, no signing call, no verification call. The only mentions of JWTs anywhere are doc comments and test fixture strings such as "jwt.header.payload" in crates/ds-auth/tests/http_auth.rs. The only real signature verification in the repository is TLS certificate verification in crates/ds-transport/src/quic.rs (rustls), which has nothing to do with tokens.
The only token implementation is an unsigned dev token, defined in crates/ds-auth/src/provider.rs:
demo.<base64url_no_pad(json(TokenClaims))>DevAuthProvider mints it, encode_token builds it, decode_claims reads it back by stripping the demo. prefix and base64-decoding. The mock “validates” it in examples/mock-stream-server/src/auth.rs by calling that same decode_claims and checking claims.is_expired(now). Nothing else is checked. Anyone can hand-craft a demo. token granting themselves enterprise and the mock will accept it.
The claim field names in code also differ from the contract. TokenClaims is:
| Contract claim | Actual struct field in provider.rs |
|---|---|
sub | account |
tier | tier |
features | features |
iat | iat_ms |
exp | exp_ms |
Because the dev token is a plain serde-JSON encoding of that struct, the keys on the wire are account, iat_ms and exp_ms — not sub, iat, exp.
The expires_at field is dead on both ends
Section titled “The expires_at field is dead on both ends”The contract puts expires_at on both the login and refresh 200 responses. Neither side uses it.
- Mock server:
json_tokeninexamples/mock-stream-server/src/http.rsemits exactly{"token": ..., "account": ...}. There is noexpires_at. - Client:
AuthResponseincrates/ds-auth/src/http.rsdeclares onlytokenand an optionalaccount. Anexpires_atin the body would be silently ignored.
The client instead learns expiry either by decoding the dev token’s claims locally, or — for an opaque token it cannot decode — by refreshing on a fixed cadence (bin/demostatics/src/setup.rs). Note that expires_at is real on the stream plane: WelcomeInfo.expires_at exists and the mock populates it.
Dev token TTL is one hour (DEV_TOKEN_TTL_MS). Production TTL is Planned — configurable, unspecified.
Stream plane
Section titled “Stream plane”Connect to wss://.../v1/stream. Framing is a versioned envelope, JSON today. crates/ds-proto/src/lib.rs notes FlatBuffers on the hot path later, with the same Envelope/Frame shape so the swap stays inside encode/decode. FlatBuffers is Planned; nothing under schemas/flatbuffers/ is wired up.
Envelope
Section titled “Envelope”Defined in crates/ds-proto/src/envelope.rs. SCHEMA_VERSION is 1.
{ "schema_ver": 1, "seq": 42, "ts": 1730000000000, "topic": "global", "frame": { "kind": "Delta", "data": { "metrics": [ /* ... */ ] } } }| Field | Type | Meaning |
|---|---|---|
schema_ver | u16 | Wire schema version. The client drops frames whose version it does not understand, without sequence accounting. |
seq | u64 | Monotonic per-connection counter, for gap detection and resync. |
ts | epoch ms | Server send time. |
topic | string | Subscription topic this frame belongs to. |
frame | tagged union | Adjacently tagged as {"kind": ..., "data": ...}. |
Handshake
Section titled “Handshake” client server | | |-- {"op":"authenticate","token":"..."} ->| | | |<-- {"kind":"Welcome","data":{...}} ------| or {"kind":"AuthError"} then close | | |-- {"op":"subscribe","topics":[...]} --->| | | |<-- {"kind":"Snapshot","data":{...}} -----| baseline |<-- {"kind":"Delta","data":{...}} --------| only what changed |<-- {"kind":"Delta", ...} ----------------| |<-- {"kind":"Heartbeat"} -----------------| keep-alive |<-- {"kind":"Usage","data":{...}} --------| refreshed allowanceAuthenticate must be the first message. The mock loops on reads until it sees an authenticate op, then either sends Welcome or sends AuthError and closes. Status: Shipped for the whole four-step sequence.
Frame kinds
Section titled “Frame kinds”Server to client, from crates/ds-proto/src/frames.rs.
kind | Payload | Status |
|---|---|---|
Welcome | { account, entitlements, usage, expires_at, taxonomy } | Shipped |
AuthError | { reason } | Shipped |
Snapshot | { metrics: [MetricTick] } — full baseline on (re)subscribe | Partial — no pagination fields, see below |
Delta | { metrics: [MetricTick] } — only what changed | Shipped |
Usage | UsageReport — refreshed remaining allowance | Shipped |
Heartbeat | none | Shipped — mock sends one every 5s |
Pong | { client_time_ms, server_time_ms } | Shipped |
Pong exists in code but is missing from the frame-kind table in API_CONTRACT.md, which lists only six kinds. The code is the truth here.
WelcomeInfo in code carries taxonomy as a fifth field; the contract’s handshake example shows only {account, entitlements, expires_at} and its frame table shows only {account, entitlements, usage, expires_at}. Both are incomplete.
Client operations
Section titled “Client operations”Client to server, from crates/ds-proto/src/subscription.rs. The tag is op, snake_case. All five are handled by the mock’s WebSocket loop in examples/mock-stream-server/src/main.rs.
op | Fields | Meaning | Status |
|---|---|---|---|
authenticate | token | Present a session token. Must be first. | Shipped |
subscribe | topics: [string] | Begin receiving frames for these topics. Re-sending it is how you resync. | Shipped |
unsubscribe | topics: [string] | Stop receiving them. Emptying the set falls back to global. | Shipped |
ping | client_time_ms | Keep-alive plus clock probe; server echoes it in Pong. | Shipped |
rate_hint | max_hz | Maximum update rate the client wants; 0 = unlimited. | Shipped, connection-wide |
rate_hint carries no topic. The mock applies it to the whole delta ticker and will only ever slow down, never speed past its configured --interval-ms. The contract’s §4 wording — “per-topic rate hints” — is Not built.
MetricTick
Section titled “MetricTick”One measurement update for one indicator.
{ "indicator": "gdp_growth", "categories": ["trade"], "value": 2.5 }categories is a list of category ids into the session taxonomy. It may be empty (uncategorized), hold one id (flat), or hold several (multi-membership, faceted). The client makes no assumptions about the set — it renders whatever the server defines. The field defaults to empty when absent from the JSON.
The mock demonstrates two of the three cases: each of its six base indicators carries exactly one category, and the premium geo_risk_index carries two (info and markets), so it appears under both. No mock indicator ships an empty categories list.
Status: Shipped.
Taxonomy
Section titled “Taxonomy”The server-defined category catalog, carried in Welcome. It is a flat list of nodes that form one or more trees, optionally grouped into named facets. Defined in crates/ds-core/src/taxonomy.rs.
"taxonomy": { "categories": [ { "id": "econ", "label": "Economy", "parent": null }, { "id": "trade", "label": "Trade", "parent": "econ" }, { "id": "markets", "label": "Markets", "parent": "econ" }, { "id": "labor", "label": "Labor", "parent": null } ]}| Field | Type | Meaning |
|---|---|---|
id | string | Stable id; what MetricTick.categories references |
label | string | Display label |
parent | string or null | Parent node id; null for a top-level node |
facet | string or null | Optional facet name (e.g. "sector", "region") grouping independent trees |
API_CONTRACT.md declares plainly that this replaces the earlier fixed five-dimension model, so the catalog can grow with no client change. The taxonomy has no counterpart anywhere in the Laravel server — there is no category catalog endpoint, no equivalent table, nothing. Category taxonomy on the server side is Not built.
Client-side handling is defensive and Shipped: an unknown or missing taxonomy is tolerated (Taxonomy::label falls back to the id itself), and root_id/path bail out after 64 hops so a cyclic parent chain from a hostile server cannot hang the client.
The mock’s catalog is seven nodes: econ (with children trade and markets), labor, energy, info, logistics.
Entitlements
Section titled “Entitlements”"entitlements": { "tier": "Pro", "features": ["dashboards", "risk-views", "premium-risk", "reports", "ai-assistant"], "allowed_topics": ["global", "category.econ", "category.trade", "..."]}Tier is an ordered enum: free < individual < pro < enterprise. The ordering is derived and tested (Tier::Free < Tier::Pro < Tier::Enterprise).
Features
Section titled “Features”Nine feature names, in their exact kebab-case wire form:
| Wire name | Rust variant |
|---|---|
dashboards | Dashboards |
risk-views | RiskViews |
premium-risk | PremiumRisk |
map-overlays | MapOverlays |
forums | Forums |
reports | Reports |
api-access | ApiAccess |
ai-assistant | AiAssistant |
distributed-compute | DistributedCompute |
Why the feature serde is hand-written
Section titled “Why the feature serde is hand-written”ds-core::identity::Feature implements Serialize and Deserialize by hand rather than deriving them, for two reasons that only bite once the server stops being this same Rust crate.
Wire format. The derive emits the variant name — "RiskViews" — while the contract specifies the kebab-case "risk-views". Today both ends of the wire are the same Rust enum, so the derive’s output agreed with itself and nothing broke. The moment a non-Rust server sends Welcome, that accidental agreement ends. The hand-written impl serializes Feature::name() and deserializes through Feature::from_name(), so the kebab-case string is the contract, not a coincidence.
The set is open. A derived Deserialize rejects an unrecognised variant, and rejecting one feature name fails the entire Welcome payload. One new capability added on the server would leave every already-shipped desktop unable to open a session at all until its user updated. Instead, from_name maps anything it does not recognise to Feature::Unknown, and Entitlements::can answers false for Unknown unconditionally — so an unknown grant can never accidentally open a gate. A newer server can add capabilities without bricking shipped desktops; older clients simply cannot use what they cannot name.
Both properties are Shipped and covered by tests in crates/ds-core/src/identity.rs.
Topics
Section titled “Topics”Structured, hierarchical topic strings. A subscription is a set of them.
| Topic | Meaning | Status |
|---|---|---|
global | Everything the tier allows | Shipped |
category.<id> | One category from the taxonomy, e.g. category.trade | Shipped |
indicator.<id> | A single indicator, e.g. indicator.gdp_growth | Partial — see below |
region.<code> | One region (ISO or internal code) | Planned — reserved, no implementation |
The rule the contract states: the server streams a metric only if it matches a subscribed topic and that topic is inside allowed_topics. Structured topics let a single chart window subscribe to exactly its dataset or category.
The mock enforces this for global and category.<id> in session::resolve_topics and session::topic_matches. It does not enforce it for indicator.<id>: those are accepted by prefix regardless of allowed_topics, because concrete indicator ids are dynamic and cannot be enumerated in the allow-list. Premium indicators stay gated, but by a different mechanism — the generator only produces geo_risk_index when the session has premium-risk. If you implement a real server, the indicator-topic allow-list is a gap you must close differently.
The mock builds allowed_topics as global plus one category.<id> per taxonomy node, identically for every tier. Per-tier topic restriction is Not built.
Usage meters
Section titled “Usage meters”The server computes billing; the client only displays how much is left. The server sends a UsageReport in Welcome and refreshes it via Usage frames.
"usage": { "meters": [ { "name": "data", "used": 20040, "limit": 100000, "unit": "MB" }, { "name": "compute", "used": 502, "limit": 5000, "unit": "credits" }, { "name": "ai", "used": 12500, "limit": 200000, "unit": "tokens" } ], "period_end": 1730000000000}limit: 0 means unlimited. UsageMeter::remaining_fraction returns 1.0 for an unlimited meter and clamps to [0, 1] otherwise; remaining saturates at zero so an over-cap meter cannot underflow. UsageReport::tightest picks the limited meter with the least left — the one to warn about. period_end is optional in code (Option<Timestamp>), though the contract shows it as always present.
Nothing here prices or bills anything. The client renders a remaining fraction per meter and stops.
No server computes it. The mock fabricates meters from the tier (session::initial_usage) and increments them on a fixed schedule (session::bump_usage, every 20 deltas). Real metering, real billing periods and real allowance enforcement are Not built — there is no billing code in any of the five repositories.
Reliability expectations
Section titled “Reliability expectations”| Expectation | Status | Where |
|---|---|---|
seq strictly increasing per connection | Shipped | Mock increments on every frame including heartbeats |
| Gap detection | Shipped | crates/ds-stream/src/engine.rs counts seq > last_seq + 1 as missed gaps |
| Resync on gap | Shipped | Client re-sends subscribe; mock replies with a fresh Snapshot |
| Snapshot adopted as a new baseline | Shipped | A Snapshot resets last_seq without gap accounting, so a reconnect whose seq restarts low is not a false gap |
| Heartbeats | Shipped | Mock every 5 seconds |
| RTT and clock-offset measurement | Shipped | crates/ds-stream/src/clock.rs — NTP-style estimate from Ping/Pong, both smoothed with an EMA |
| Reconnect with backoff | Shipped | crates/ds-transport/src/reconnect.rs — exponential with full jitter, 250 ms to 15 s |
| Per-topic rate hints | Not built | rate_hint is connection-wide |
| Resume token to replay missed deltas | Not built | The contract calls it “desired” twice; there is no such field anywhere in ds-proto |
Snapshot pagination
Section titled “Snapshot pagination”API_CONTRACT.md §5 states that a Snapshot frame “carries cursor and complete fields”, that the server sends N chunks each under a size cap (default 1 MB / ~5k metrics) ending with complete: true, and that the client applies deltas only after the baseline completes.
The Snapshot struct in crates/ds-proto/src/frames.rs is:
pub struct Snapshot { pub metrics: Vec<MetricTick>,}There is no cursor and no complete field, in ds-proto or anywhere else — a search for either name across the crates returns nothing relevant. §6 decision #4 admits in a parenthetical that “chunking code lands when a real large baseline exists”, but §5’s main sentence reads as present tense and is misleading. Snapshot pagination is Planned, not implemented.
The parallel planes
Section titled “The parallel planes”Three surfaces sit alongside the WebSocket stream, all behind the ds-transport seam, all feature-gated in both the client (--features remote) and the mock.
crates/ds-transport/src/quic.rs (quinn + rustls), mock side in examples/mock-stream-server/src/quic.rs. Same protocol as WebSocket — authenticate, subscribe, envelopes — different framing: length-prefixed JSON, u32 big-endian length followed by the JSON bytes, on a single bidirectional stream opened with open_bi().
TLS trust is governed by TlsPolicy, and the always-insecure behaviour is gone — you must opt in:
| Mode | Behaviour |
|---|---|
| Default | Full WebPKI chain plus hostname validation against the Mozilla root store |
| Pinned | WebPKI validation, then the end-entity certificate’s SHA-256 must match one of the configured pins. Pinning never replaces normal validation. Pins parse as 64-char hex or base64 |
insecure | Certificate validation disabled entirely. Logs a warning. Dev and self-signed only |
The mock’s QUIC server uses an rcgen self-signed certificate for localhost, so reaching it requires the insecure opt-in. Status: Shipped in client and mock, Not built on any real server.
The unary query plane, for backfill, snapshots and exports — the request/response counterpart to the stream.
- Schema:
demostatics-pc_application/schemas/proto/query.proto - Package:
demostatics.query.v1 - Service and method:
Query/Backfill, takingBackfillRequest { indicator, from_ms, to_ms }and returningBackfillResponse { points: [{ ts_ms, value }] } - Auth:
authorization: Bearer <token>in request metadata, validated by the sameauth::validatethe stream uses. An unauthenticated call getsStatus::unauthenticated.
Status: Shipped in crates/ds-transport/src/grpc.rs and examples/mock-stream-server/src/grpc.rs, with a synthetic point generator behind it.
AI chat
Section titled “AI chat”POST /v1/ai/chat, server-sent events. Client in crates/ds-ai/src/http.rs, reference implementation in the mock’s http.rs. It authenticates the Bearer token, checks Feature::AiAssistant, then streams a deterministic agent loop as SSE events: tool activity, text deltas, a usage record, then done. Failure modes are 401 {"error":"unauthorized"} and 403 {"error":"ai_not_entitled"}.
The five decisions of v0.2
Section titled “The five decisions of v0.2”API_CONTRACT.md §6 closes out five formerly-open questions. Reproduced here with the stated rationale, plus what actually exists.
| # | Question | Decision | Rationale | Actual status |
|---|---|---|---|---|
| 1 | Streaming transport | WSS now, QUIC (quinn) as long-term primary with WSS as universal fallback; gRPC (tonic) for the unary query/backfill/export plane | WSS reaches through every proxy today; QUIC gives per-topic stream-loss isolation, 0-RTT resume and connection migration for a long-lived client; gRPC is the right shape for request/response queries. All three sit behind ds-transport | Shipped in client and mock (all three). 0-RTT resume and connection migration are Not built |
| 2 | Token format, claims, refresh | Signed JWT carrying sub, tier, features, iat, exp; /v1/auth/refresh before expiry; dev TTL 1 h, prod configurable | Claims let the client gate UI instantly and reconnect without re-login; the stream server still verifies signature and exp | Partial — claims, refresh and the 1 h dev TTL exist; signing and verification are Not built |
| 3 | Topic taxonomy | global / category.<id> / indicator.<id> / region.<code>, entitlement-gated, per-widget subscriptions allowed | Structured topics let a single window subscribe to exactly its dataset or category, cutting bandwidth and enabling fine-grained gating | Partial — global and category.<id> fully gated; indicator.<id> accepted by prefix outside the allow-list; region.<code> Planned |
| 4 | Snapshot pagination | Cursor-chunked Snapshot with a complete flag and a size cap; deltas apply after completion | Bounds memory and first-paint latency for large baselines while staying a single frame for small ones | Planned — no cursor or complete field exists |
| 5 | api-access tier | A separate partner API surface (REST and gRPC), distinct from the desktop stream, same auth and entitlement model, its own rate limits | Keeps the interactive client path lean and lets institutional integrations evolve independently; Feature::ApiAccess gates issuance of partner API keys | Not built — only the api-access feature name exists. There is no partner API surface, no key issuance, no rate limiter |
Dev accounts
Section titled “Dev accounts”The mock accepts four accounts, all with password demo. Anything else produces an AuthError on the stream plane or 401 {"error":"invalid_credentials"} on the auth plane.
Addresses that actually work, with the features each is granted:
| Tier | Features granted | |
|---|---|---|
free@example.com | free | dashboards, forums |
individual@example.com | individual | dashboards, forums, risk-views |
pro@example.com | pro | dashboards, forums, risk-views, premium-risk, reports, ai-assistant |
enterprise@example.com | enterprise | all nine |
Only pro and enterprise hold premium-risk, so only they receive the geo_risk_index indicator. Only enterprise holds map-overlays, api-access and distributed-compute.
The mock’s default listen addresses are 127.0.0.1:9001 for WebSocket (always on), 127.0.0.1:9010 for HTTP auth, [::1]:9020 for QUIC and 127.0.0.1:9030 for gRPC. The last three are Cargo-feature-gated (http-auth, quic, grpc) and off unless you build them in.
Where this page disagrees with API_CONTRACT.md
Section titled “Where this page disagrees with API_CONTRACT.md”Collected for anyone about to implement a real server. In each case the code is the truth and the document is out of date or aspirational.
| Contract says | Code does |
|---|---|
| Signed JWT, signature verified by the stream server | Unsigned demo.<base64url> token, decoded and expiry-checked only. No JWT library in the workspace |
Claims sub, iat, exp | Fields account, iat_ms, exp_ms |
expires_at on login and refresh responses | Not emitted by the mock, not parsed by the client |
"tier": "pro" | Serializes as "Pro" — derived serde, no rename |
| Six frame kinds | Seven — Pong is undocumented |
Welcome carries {account, entitlements, usage, expires_at} | Also carries taxonomy |
Snapshot carries cursor and complete | Snapshot has only metrics |
| Per-topic rate hints | rate_hint is connection-wide, no topic field |
Server streams only topics inside allowed_topics | True for global and category.*; indicator.* is accepted by prefix |
Dev accounts at @demostatics.com | @example.com |
| (silent) | POST /v1/ai/chat exists in code and is entirely undocumented |
| ”gRPC for the query plane” | demostatics.query.v1.Query/Backfill in schemas/proto/query.proto — never named in the contract |