Skip to content

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.

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:

Terminal window
cd demostatics-pc_application
grep -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.

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.

AxisDesktop expectsServer actually serves
Path prefix/v1/… (/v1/auth/login, wss://…/v1/stream)/api/v1/…
Token typeSigned JWT carrying sub, tier, features[], iat, expOpaque Sanctum personal access token (3|xxxxxxxx), no claims
RefreshPOST /v1/auth/refresh before exp; dev TTL 1 hNo refresh endpoint. Tokens expire (SANCTUM_EXPIRATION, default 30 days); a 401 means sign in again
Entitlement modeltier (free/individual/pro/enterprise) + features[] + allowed_topics[], server-enforcedNone. Access tiers are public / auth / verified, driven by email_verified_at and roles
TransportWSS now, QUIC (quinn) as long-term primary, gRPC (tonic) for query and backfillREST over HTTP only. No WebSocket, no QUIC, no gRPC
Billing / usageusage.meters — data MB, compute credits, AI tokens, with period_end; server computes billingNothing. No meters, no plans, no billing of any kind
Classification modelServer-defined taxonomy: flat list of Category { id, label, parent } nodes forming trees, multi-membership per metricSeven fixed nullable columns on reports and database_items: region_id, subregion_id, country_id, state_id, city_id, category_id, subcategory_id
Value typeMetricTick { indicator, categories, value: f64 } — numeric, streamed as Snapshot + Deltadatabase_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.

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.

There are not two competing ways to classify a dataset on this platform. There are three.

ModelWhere it livesStatus
Seven fixed nullable dimension columnsdemostatics-web migrations for reports and database_items; the filter forms and /api/v1 query parametersShipped
Server-defined taxonomydemostatics-pc_application/crates/ds-core/src/taxonomy.rs, delivered in the Welcome framePartial — client and mock implement it; no real server emits it
Fixed five-dimension modelSuperseded by the taxonomy, but a vestige of it survives in the desktop codePartial — see below

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.

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.

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.

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.

EndpointAPI accessWeb twinWeb access
GET /api/v1/regionspublicnone — no /regions route in routes/web.phpn/a
GET /api/v1/regions/{id}/subregionspublicGET /subregions/{regionId}['auth','verified']
GET /api/v1/subregions/{id}/countriespublicGET /countries/{subregionId}['auth','verified']
GET /api/v1/countries/{id}/statespublicGET /states/{countryId}['auth','verified']
GET /api/v1/states/{id}/citiespublicGET /cities/{stateId}['auth','verified']
GET /api/v1/categories/{id}/subcategoriespublicGET /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.

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:

ItemWhat has to be decided
Identity authorityOne system owns accounts, and one token-exchange endpoint bridges to the other
Feature vocabularyOne set of feature names, one wire spelling, used by both clients
403 bodyOne shape with a stable machine-readable code, so a client can tell “upgrade” from “verify” from “banned” from “forbidden”
Device registryOne place a device is enrolled, revoked and listed, for both desktop and phone
Dataset identifier namespaceOne id space for datasets and categories, shared with the worker that will emit them
ClassificationTaxonomy over fixed dimensions — pick one and delete the other
Usage metersAn 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.