The Two Contracts
Demostatics has two API contracts. They disagree on path, token, transport, entitlements, classification and value type, and one of them has no server. This page exists so nobody rediscovers that the hard way.
The situation in three sentences
Section titled “The situation in three sentences”demostatics-web serves /api/v1 — a REST API authenticated with opaque Laravel Sanctum personal access tokens. The mobile app is a real client of it: demostatics-mobile_application/src/lib/config.ts sets API_BASE to ${API_ORIGIN}/api/v1, and every screen reads from there. The desktop client targets a different contract entirely — /v1 paths, signed JWTs carrying claims, WebSocket streaming with QUIC and gRPC planes — described in demostatics-pc_application/docs/API_CONTRACT.md v0.2 and served by nothing except that repo’s own examples/mock-stream-server.
The desktop client has never been pointed at Laravel. Run the grep yourself:
cd demostatics-pc_applicationgrep -rIn --exclude-dir=target --exclude-dir=dist -iE 'api/v1|laravel|sanctum' .# (no output; exit code 1)Zero hits across the entire repository — source, tests, docs, examples and manifests. The desktop’s default server is ws://127.0.0.1:9001 (bin/demostatics/src/cli.rs), which is the local mock.
Where they disagree
Section titled “Where they disagree”Every row below is read from demostatics-pc_application/docs/API_CONTRACT.md on the left and from demostatics-web/routes/api.php, demostatics-web/docs/api-v1.md and the Laravel migrations on the right.
| Axis | Desktop expects | Server actually serves |
|---|---|---|
| Path prefix | /v1/… (/v1/auth/login, wss://…/v1/stream) | /api/v1/… |
| Token type | Signed JWT carrying sub, tier, features[], iat, exp | Opaque Sanctum personal access token (3|xxxxxxxx), no claims |
| Refresh | POST /v1/auth/refresh before exp; dev TTL 1 h | No refresh endpoint. Tokens expire (SANCTUM_EXPIRATION, default 30 days); a 401 means sign in again |
| Entitlement model | tier (free/individual/pro/enterprise) + features[] + allowed_topics[], server-enforced | None. Access tiers are public / auth / verified, driven by email_verified_at and roles |
| Transport | WSS now, QUIC (quinn) as long-term primary, gRPC (tonic) for query and backfill | REST over HTTP only. No WebSocket, no QUIC, no gRPC |
| Billing / usage | usage.meters — data MB, compute credits, AI tokens, with period_end; server computes billing | Nothing. No meters, no plans, no billing of any kind |
| Classification model | Server-defined taxonomy: flat list of Category { id, label, parent } nodes forming trees, multi-membership per metric | Seven fixed nullable columns on reports and database_items: region_id, subregion_id, country_id, state_id, city_id, category_id, subcategory_id |
| Value type | MetricTick { indicator, categories, value: f64 } — numeric, streamed as Snapshot + Delta | database_items.value is a nullable string (VARCHAR) holding a display string, plus one measured_at. No history |
Two details the table cannot hold.
The dev token is not actually a JWT. ds-auth’s dev provider mints demo.<base64url(claims-json)>, which is unsigned, and the mock decodes it without verifying anything. A grep of the PC repo for jsonwebtoken, jwks, RS256 or HS256 returns nothing — no code anywhere mints or verifies a signed token. The JWT is Planned on both sides, not just the server side.
The allowed_topics vocabulary (global, category.<id>, indicator.<id>, region.<code>) has no counterpart at all on the Laravel side, because Laravel has no streaming plane to gate.
Why this is not cosmetic
Section titled “Why this is not cosmetic”The two clients do not share an account. There is one user row for the mobile app, in Laravel’s users table, and a hardcoded dev account table in crates/ds-auth/src/provider.rs for the desktop. Nothing connects them.
They do not share an entitlement model. One side has tier and features[]; the other has no concept of either.
They do not share a dataset identifier namespace. The desktop’s CategoryId is a String (crates/ds-core/src/taxonomy.rs) holding values like "econ", "trade", "markets", "labor". Laravel’s category_id is an auto-increment integer pointing at rows named local news, organizational sectors, meta descriptive listings, transportation traffic, ecological conditions (database/seeders/CategorySeeder.php). No mapping exists in either direction, in code or on paper.
They do not share a data shape. One consumes f64; the other stores "48,210 MW".
So “connect mobile to the desktop” is not first a pairing feature. It is a contract problem, and pairing is what you build after it is solved. demostatics-mobile_application/docs/roadmap.md §1 calls this “the finding that reorders everything” and prices settling it on paper at one week of nobody writing code, as Phase 1. Every later phase in that plan assumes Phase 1 is done. See the roadmap for the phase list.
Three classification models coexist
Section titled “Three classification models coexist”There are not two competing ways to classify a dataset on this platform. There are three.
| Model | Where it lives | Status |
|---|---|---|
| Seven fixed nullable dimension columns | demostatics-web migrations for reports and database_items; the filter forms and /api/v1 query parameters | Shipped |
| Server-defined taxonomy | demostatics-pc_application/crates/ds-core/src/taxonomy.rs, delivered in the Welcome frame | Partial — client and mock implement it; no real server emits it |
| Fixed five-dimension model | Superseded by the taxonomy, but a vestige of it survives in the desktop code | Partial — see below |
The five-dimension vestige
Section titled “The five-dimension vestige”API_CONTRACT.md says the taxonomy “replaces the earlier fixed five-dimension model”, and crates/ds-core/src/taxonomy.rs’s own header comment repeats the claim.
The replacement is mostly real, but not complete. crates/ds-ai/src/lib.rs still declares:
pub struct AiIndicator { pub id: String, pub dimension: String, // ...}That dimension: String field is the old model’s shape. What fills it today is taxonomy-derived, not a fixed dimension — bin/demostatics/src/gui/modules.rs builds the AI context with dimension: i.category_label.clone(), and category_label is computed in bin/demostatics/src/gui/mod.rs by resolving each of an indicator’s categories ids through the session taxonomy and joining the labels, falling back to "uncategorized" for empty membership.
So the field name is a leftover, and the values flowing through it in production are taxonomy labels. The only places where genuine five-dimension strings like "capital" and "information" still appear are test fixtures in crates/ds-ai/src/lib.rs and a mock fixture in examples/mock-stream-server/src/http.rs. It is a naming vestige with fixture-level residue, not a live third model — but it is a String field with no validation, so anything can be put in it, and the prompt the assistant sees carries whatever is there.
Nothing maps Laravel’s category_id / region_id space onto the desktop’s CategoryId space. Building that mapping — or, per the roadmap’s recommendation, picking the taxonomy and modelling geography as a facet of it — is part of the Phase 1 work.
Value types
Section titled “Value types”database_items.value is declared $table->string('value')->nullable() in demostatics-web/database/migrations/2026_07_17_130000_create_database_items_table.php. It holds display strings. The seeder in database/seeders/DatabaseItemSeeder.php writes rows such as:
'US electricity demand' '48,210 MW''California spot price' '62.4 USD/MWh''Western Europe air quality' '38 AQI'Each row carries one measured_at. There is no observations table, no history, no unit column, no numeric type anywhere. The mobile app’s DatabaseItem type declares value: string | null and treats it as text.
The desktop consumes MetricTick { indicator: IndicatorId, categories: Vec<CategoryId>, value: f64 } (crates/ds-proto/src/frames.rs) and its entire analytics stack — descriptive statistics, curve fitting, forecasting, the risk aggregation, the alert rules — is built on f64 series with a rolling history window.
Nothing on the server side can feed it. You cannot compute a standard deviation from "48,210 MW" without a parser, and the roadmap explicitly rules that out: convert the seeded rows by hand, do not ship a regex parser to recover values the worker already holds as f64. Producing real numerics is Phase 2 (metrics, metric_observations, metric_rollups, a units table), and it depends on a worker tier that does not exist in any repository.
Entitlements exist on one side only
Section titled “Entitlements exist on one side only”API_CONTRACT.md asserts that “the server supplies data + auth + entitlements”, that “the server is the authority on entitlements — the client never decides what a subscription unlocks”, and that “the server computes billing; the client only displays how much is left”.
The server it is talking about does not implement any of that. Reading demostatics-web/database/migrations/, the full list is: users, cache, jobs, editors, about-us articles, categories, subcategories, editorial articles, personal access tokens, reports, posts, communities, comments, questions, permission tables, bans, polls, poll options, votes, tasks, forum indexes, database items.
There is no plans table, no features table, no subscriptions table, no entitlement_grants table and no usage or metering table. The users migration creates exactly id, name, email, email_verified_at, password, remember_token and timestamps — no tier column, and no later migration adds one.
On the mobile side there is no entitlement concept at all. src/lib/api/types.ts has no tier, entitlement, feature, plan, subscription, usage or meter anywhere; the User interface is id, name, email?, email_verified, roles, is_banned, is_online, created_at.
What Laravel actually has is a permission model — roles and bans via spatie/laravel-permission, plus email verification — which answers “may this person moderate” and not “has this person paid for premium risk views”. Those are different questions and the second one has no implementation.
Entitlements are Not built server-side. The desktop’s enforcement is Shipped against the mock only.
Documentation that is currently wrong
Section titled “Documentation that is currently wrong”Each item below was checked against the source before being listed. Fixing them is cheap and prevents the next reader from inheriting the confusion.
The mobile README’s architecture diagram
Section titled “The mobile README’s architecture diagram”demostatics-mobile_application/README.md draws demostatics-pc_application and demostatics-mobile as two branches under “demostatics-web / display tier + /api/v1”, and the prose says “It is the mobile counterpart to the desktop client: both are consumers of the same display tier.”
The desktop is not a consumer of /api/v1. The zero-hit grep above is the proof, and the same repository’s own docs/roadmap.md §1 says the opposite in its first line: “There are two API contracts and one of them is not implemented.”
Worth knowing: git history shows this diagram has already been corrected once. Commit ca4e6fe (“Correct the architecture diagram: the PC client is not a worker”) replaced an earlier version that drew the desktop as a worker tier. The correction fixed one wrong claim and introduced another.
The roadmap’s own comparison table says tokens never expire
Section titled “The roadmap’s own comparison table says tokens never expire”demostatics-mobile_application/docs/roadmap.md §1 lists the mobile side’s token as “opaque Sanctum, no refresh, no expiry”. §3 of the same file, under “Done”, says config/sanctum.php was 'expiration' => null and now uses SANCTUM_EXPIRATION at 30 days with sanctum:prune-expired on the daily schedule.
The config confirms §3: demostatics-web/config/sanctum.php reads 'expiration' => env('SANCTUM_EXPIRATION', 60 * 24 * 30). The §1 table is stale on expiry. “No refresh” is still true — there is no refresh endpoint in routes/api.php.
”Access tiers mirror the web routes exactly” is not true of geo or subcategories
Section titled “”Access tiers mirror the web routes exactly” is not true of geo or subcategories”The header comment in demostatics-web/routes/api.php claims “Access tiers mirror the web routes exactly”, and demostatics-web/docs/api-v1.md repeats it (“Mirrors the web app’s ['auth', 'verified'] group”). For the forum, polls, editorial, Reports and Database that holds. For the geo cascade and subcategories it does not.
| Endpoint | API access | Web twin | Web access |
|---|---|---|---|
GET /api/v1/regions | public | none — no /regions route in routes/web.php | n/a |
GET /api/v1/regions/{id}/subregions | public | GET /subregions/{regionId} | ['auth','verified'] |
GET /api/v1/subregions/{id}/countries | public | GET /countries/{subregionId} | ['auth','verified'] |
GET /api/v1/countries/{id}/states | public | GET /states/{countryId} | ['auth','verified'] |
GET /api/v1/states/{id}/cities | public | GET /cities/{stateId} | ['auth','verified'] |
GET /api/v1/categories/{id}/subcategories | public | GET /subcategories/{categoryId} | ['auth','verified'] |
All six web twins sit inside the Route::middleware(['auth', 'verified']) group at the bottom of routes/web.php. Their API counterparts are declared in the public block of routes/api.php, above auth:sanctum.
This is probably the correct behaviour — a lookup cascade for a filter dropdown is not sensitive, and gating it behind verified on the web is arguably the anomaly. The problem is the claim, not the routing. Either loosen the web routes or amend both documents to state the exception.
The desktop’s documented dev accounts do not exist
Section titled “The desktop’s documented dev accounts do not exist”API_CONTRACT.md and demostatics-pc_application/README.md both table the dev accounts as free@demostatics.com, individual@demostatics.com, pro@demostatics.com, enterprise@demostatics.com, and the README’s copy-pasteable commands use pro@demostatics.com.
The code accepts a different set. crates/ds-auth/src/provider.rs matches on exactly free@example.com, individual@example.com, pro@example.com and enterprise@example.com, and anything else returns an error. The CLI’s own help text in bin/demostatics/src/cli.rs says example.com and defaults --email to pro@example.com.
Every documented login command fails as written. The sub claim example in API_CONTRACT.md §1 has the same wrong domain.
What settling it requires
Section titled “What settling it requires”demostatics-mobile_application/docs/roadmap.md Phase 1 is one week of amending both contract documents so they agree, with no code written. Its list:
| Item | What has to be decided |
|---|---|
| Identity authority | One system owns accounts, and one token-exchange endpoint bridges to the other |
| Feature vocabulary | One set of feature names, one wire spelling, used by both clients |
| 403 body | One shape with a stable machine-readable code, so a client can tell “upgrade” from “verify” from “banned” from “forbidden” |
| Device registry | One place a device is enrolled, revoked and listed, for both desktop and phone |
| Dataset identifier namespace | One id space for datasets and categories, shared with the worker that will emit them |
| Classification | Taxonomy over fixed dimensions — pick one and delete the other |
| Usage meters | An owner for usage.meters, or a decision that they do not exist yet |
The roadmap’s recommendation on the first item is specific, and it is the one with a security argument attached: Laravel owns users, roles, bans and verification, and mints a short-lived RS256 ticket that the stream backend verifies offline via JWKS. The reason is stated plainly in §2.3 — anything else means a revoked phone keeps its data stream.
That shape is compatible with the desktop contract’s design intent (claims-bearing token, short TTL, re-verified by the stream server on every connect) while keeping a single revocation point. It is Planned: nothing implements RS256, JWKS or ticket exchange in any repository today.
Related pages
Section titled “Related pages”- The JSON API v1 — what
demostatics-webactually serves today. - The stream contract — what the desktop expects, in full.
- The roadmap — the phase plan this page’s Phase 1 comes from.
- Open questions — the decisions that block work.
- The system map — how the five repositories relate.