Skip to content

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 hereMeans
ShippedImplemented in the desktop repo and exercised against the in-repo mock server
PartialSome of it exists in code, with named gaps
PlannedWritten into API_CONTRACT.md, no code
Not builtNo 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 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.

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> }
PieceStatusNote
POST /v1/auth/loginShipped (client + mock)HttpAuthProvider::login; mock route in http.rs
POST /v1/auth/refreshShipped (client + mock)Bearer in, new token out
Signed JWTNot builtSee below — no signing, no verification, anywhere
expires_at on either responseNot builtSpecified in the contract; neither emitted nor parsed
Token in the OS keychainShippedds-auth::SecretStore over keyring

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.

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 claimActual struct field in provider.rs
subaccount
tiertier
featuresfeatures
iatiat_ms
expexp_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 contract puts expires_at on both the login and refresh 200 responses. Neither side uses it.

  • Mock server: json_token in examples/mock-stream-server/src/http.rs emits exactly {"token": ..., "account": ...}. There is no expires_at.
  • Client: AuthResponse in crates/ds-auth/src/http.rs declares only token and an optional account. An expires_at in 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.

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.

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": [ /* ... */ ] } } }
FieldTypeMeaning
schema_veru16Wire schema version. The client drops frames whose version it does not understand, without sequence accounting.
sequ64Monotonic per-connection counter, for gap detection and resync.
tsepoch msServer send time.
topicstringSubscription topic this frame belongs to.
frametagged unionAdjacently tagged as {"kind": ..., "data": ...}.
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 allowance

Authenticate 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.

Server to client, from crates/ds-proto/src/frames.rs.

kindPayloadStatus
Welcome{ account, entitlements, usage, expires_at, taxonomy }Shipped
AuthError{ reason }Shipped
Snapshot{ metrics: [MetricTick] } — full baseline on (re)subscribePartial — no pagination fields, see below
Delta{ metrics: [MetricTick] } — only what changedShipped
UsageUsageReport — refreshed remaining allowanceShipped
HeartbeatnoneShipped — 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 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.

opFieldsMeaningStatus
authenticatetokenPresent a session token. Must be first.Shipped
subscribetopics: [string]Begin receiving frames for these topics. Re-sending it is how you resync.Shipped
unsubscribetopics: [string]Stop receiving them. Emptying the set falls back to global.Shipped
pingclient_time_msKeep-alive plus clock probe; server echoes it in Pong.Shipped
rate_hintmax_hzMaximum 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.

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.

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 }
]
}
FieldTypeMeaning
idstringStable id; what MetricTick.categories references
labelstringDisplay label
parentstring or nullParent node id; null for a top-level node
facetstring or nullOptional 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": {
"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).

Nine feature names, in their exact kebab-case wire form:

Wire nameRust variant
dashboardsDashboards
risk-viewsRiskViews
premium-riskPremiumRisk
map-overlaysMapOverlays
forumsForums
reportsReports
api-accessApiAccess
ai-assistantAiAssistant
distributed-computeDistributedCompute

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.

Structured, hierarchical topic strings. A subscription is a set of them.

TopicMeaningStatus
globalEverything the tier allowsShipped
category.<id>One category from the taxonomy, e.g. category.tradeShipped
indicator.<id>A single indicator, e.g. indicator.gdp_growthPartial — 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.

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.

ExpectationStatusWhere
seq strictly increasing per connectionShippedMock increments on every frame including heartbeats
Gap detectionShippedcrates/ds-stream/src/engine.rs counts seq > last_seq + 1 as missed gaps
Resync on gapShippedClient re-sends subscribe; mock replies with a fresh Snapshot
Snapshot adopted as a new baselineShippedA Snapshot resets last_seq without gap accounting, so a reconnect whose seq restarts low is not a false gap
HeartbeatsShippedMock every 5 seconds
RTT and clock-offset measurementShippedcrates/ds-stream/src/clock.rs — NTP-style estimate from Ping/Pong, both smoothed with an EMA
Reconnect with backoffShippedcrates/ds-transport/src/reconnect.rs — exponential with full jitter, 250 ms to 15 s
Per-topic rate hintsNot builtrate_hint is connection-wide
Resume token to replay missed deltasNot builtThe contract calls it “desired” twice; there is no such field anywhere in ds-proto

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.

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:

ModeBehaviour
DefaultFull WebPKI chain plus hostname validation against the Mozilla root store
PinnedWebPKI 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
insecureCertificate 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, taking BackfillRequest { indicator, from_ms, to_ms } and returning BackfillResponse { points: [{ ts_ms, value }] }
  • Auth: authorization: Bearer <token> in request metadata, validated by the same auth::validate the stream uses. An unauthenticated call gets Status::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.

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"}.

API_CONTRACT.md §6 closes out five formerly-open questions. Reproduced here with the stated rationale, plus what actually exists.

#QuestionDecisionRationaleActual status
1Streaming transportWSS now, QUIC (quinn) as long-term primary with WSS as universal fallback; gRPC (tonic) for the unary query/backfill/export planeWSS 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-transportShipped in client and mock (all three). 0-RTT resume and connection migration are Not built
2Token format, claims, refreshSigned JWT carrying sub, tier, features, iat, exp; /v1/auth/refresh before expiry; dev TTL 1 h, prod configurableClaims let the client gate UI instantly and reconnect without re-login; the stream server still verifies signature and expPartial — claims, refresh and the 1 h dev TTL exist; signing and verification are Not built
3Topic taxonomyglobal / category.<id> / indicator.<id> / region.<code>, entitlement-gated, per-widget subscriptions allowedStructured topics let a single window subscribe to exactly its dataset or category, cutting bandwidth and enabling fine-grained gatingPartialglobal and category.<id> fully gated; indicator.<id> accepted by prefix outside the allow-list; region.<code> Planned
4Snapshot paginationCursor-chunked Snapshot with a complete flag and a size cap; deltas apply after completionBounds memory and first-paint latency for large baselines while staying a single frame for small onesPlanned — no cursor or complete field exists
5api-access tierA separate partner API surface (REST and gRPC), distinct from the desktop stream, same auth and entitlement model, its own rate limitsKeeps the interactive client path lean and lets institutional integrations evolve independently; Feature::ApiAccess gates issuance of partner API keysNot built — only the api-access feature name exists. There is no partner API surface, no key issuance, no rate limiter

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:

EmailTierFeatures granted
free@example.comfreedashboards, forums
individual@example.comindividualdashboards, forums, risk-views
pro@example.comprodashboards, forums, risk-views, premium-risk, reports, ai-assistant
enterprise@example.comenterpriseall 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 saysCode does
Signed JWT, signature verified by the stream serverUnsigned demo.<base64url> token, decoded and expiry-checked only. No JWT library in the workspace
Claims sub, iat, expFields account, iat_ms, exp_ms
expires_at on login and refresh responsesNot emitted by the mock, not parsed by the client
"tier": "pro"Serializes as "Pro" — derived serde, no rename
Six frame kindsSeven — Pong is undocumented
Welcome carries {account, entitlements, usage, expires_at}Also carries taxonomy
Snapshot carries cursor and completeSnapshot has only metrics
Per-topic rate hintsrate_hint is connection-wide, no topic field
Server streams only topics inside allowed_topicsTrue 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