Skip to content

Conventions

Demostatics is four working repositories in three languages, plus a placeholder. This page is the reference for the rules that hold across them: where a given piece of code belongs, and what each language’s repository expects of a change before it lands.

Every rule here was read out of the source files rather than out of a style guide. Where a repository states a rule but does not enforce it, that is said in words.

Put code in the repository that owns the concern. The table is the whole rule.

RepositoryOwnsDoes not ownStatus
demostatics-webUsers, authentication, roles, bans, the forum, polls, editorial, and the storage and display of reports and database valuesGathering or processing any dataShipped
demostatics-pc_applicationClient-side statistics, correlation, regression, risk index and streaming aggregation, computed on the user’s own machineIdentity — it has no account of recordShipped (engines, transport and the egui GUI all exist; it targets its own in-repo mock server, not Demostatics)
demostatics-mobile_applicationPresentation of demostatics-web data on a phoneAny computation of its ownShipped
demostatics-backendNothing. One README file, two lines, no codeEverything it is named forNot built
demostatics-docsThis siteAny runtime behaviourShipped

The load-bearing rule is the display tier’s own, stated in demostatics-web/README.md:

This repository does not gather or process anything.

It reads worker-produced rows out of its own database and renders them. If a change to demostatics-web starts fetching an external feed, scoring it, or scheduling a job, it is in the wrong repository. The tier that would own that work does not exist in code anywhere — see the worker tier.

The desktop client is not a client of demostatics-web. It targets its own unimplemented contract and talks only to its in-repo mock server. Do not add code to either repository that assumes the other is reachable; see the two contracts.

The stack is PHP 8.3, Laravel 11 and Sanctum 4, with spatie/laravel-permission for roles. All of it is Shipped.

Pest 3 with the Laravel plugin. Run either form:

Terminal window
php artisan test
vendor/bin/pest

tests/Pest.php binds every test to Tests\TestCase with RefreshDatabase, so tests get a fresh schema and need no setup. phpunit.xml runs them against in-memory SQLite and pins WORLD_SQLITE_PATH to a nonexistent file on purpose, so the world geo store is always absent under test.

Write tests in Pest’s function style (it('...', function () { ... })), matching everything already in tests/Feature.

Laravel Pint, installed as a dev dependency:

Terminal window
vendor/bin/pint

There is no pint.json in the repository, so Pint runs its default laravel preset. Do not add a config file to win an argument about a single rule — take the preset.

App-specific tunables live in demostatics-web/config/demostatics.php, not in controllers. That file currently holds pagination size, the password strength policy, form length limits, the presence TTL and the export row cap, each overridable from .env. Twenty-seven call sites in app/ read it through config('demostatics.*').

Two things stay in code deliberately, because they are structural rather than values to tune:

ThingWhere
Auth and password-reset rate limitsroutes/auth.php, routes/api.php (throttle:5,1, throttle:6,1)
Rolesthe UserRole enum, app/Models/UserRole.php

After editing config/*.php in production, run php artisan config:clear.

Policies live in app/Policies and are auto-discovered by Laravel 11’s naming convention — PostPolicy for Post, and so on. There is no AuthServiceProvider and no Gate::policy() call anywhere in the repository. Name a new policy after its model and it is wired up; register it manually and you have added a second source of truth.

The API’s permission hints come from the same policies. PostResource, PollResource and CommentResource each emit a can block computed with $request->user()?->can(...):

'can' => [
'update' => (bool) $user?->can('update', $this->resource),
'delete' => (bool) $user?->can('delete', $this->resource),
],

That is the same PostPolicy the Blade @can blocks consult. A client shows or hides an edit affordance by reading can, never by reimplementing ownership and role-rank rules. When you change a policy, both the web UI and every API client change with it.

The app must behave the same on SQLite, MySQL/MariaDB, PostgreSQL and SQL Server. Two idioms exist because a naive query does not. Both are real and both are tested.

Case-insensitive search. A bare LIKE is case-sensitive on PostgreSQL, so search lowers both sides:

$q->whereRaw('LOWER(title) LIKE ?', [$term])
->orWhereRaw('LOWER(content) LIKE ?', [$term]);

The term is lowered in PHP with mb_strtolower() before binding. This appears in app/Http/Controllers/PostController.php, app/Http/Controllers/PollController.php, and once centrally in ApiController::applySearch() (app/Http/Controllers/Api/V1/ApiController.php), which the API controllers share.

NULLs last. PostgreSQL sorts NULLs first on a DESC order, and drivers disagree in general, so nullable date columns get an explicit portable CASE ahead of the real ordering:

$query->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
->orderByDesc('published_at');

This appears in app/Http/Controllers/ReportsController.php, app/Http/Controllers/DatabaseController.php, app/Http/Controllers/Api/V1/ReportController.php and app/Http/Controllers/Api/V1/DatabaseItemController.php. Do not reach for NULLS LAST — SQLite and MySQL do not accept it.

tests/Feature/PortabilityTest.php guards both idioms plus the world-store-unreachable path. Add a case there when you add a query that could differ by driver.

A Cargo workspace of 20 members: 18 ds-* crates, the demostatics binary, and examples/mock-stream-server.

justfile is canonical. Use the recipe, not the raw cargo invocation, so local runs match CI:

Terminal window
just build # cargo build --workspace
just test # cargo test --workspace
just lint # cargo clippy --workspace --all-targets -- -D warnings
just fmt # cargo fmt --all
just fmt-check # cargo fmt --all --check
just serve # the local mock WebSocket backend
just demo # mock server + 50 client frames, end to end

.github/workflows/ci.yml runs cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings on every pull request, then cargo test --workspace, a benchmark compile, feature-gated clippy runs for gui, remote and the reference server, and cargo-deny for advisories, licences and bans. A clippy warning fails the build. Run just lint before you push.

This is the only repository in the platform with CI. Status: Shipped for the desktop repo, Not built everywhere else.

The workspace root sets the lint policy and every one of the 20 member manifests opts in with [lints] workspace = true:

[workspace.lints.rust]
unsafe_code = "deny"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }

Group lints carry priority = -1 so an individual lint can override them. unsafe_code is denied workspace-wide; there is currently no #![allow(unsafe_code)] anywhere. Relaxing it is a per-crate, per-justification decision, not a default.

rustfmt.toml sets max_width = 100. rust-toolchain.toml pins the stable channel with rustfmt and clippy components, so everyone formats identically.

Cargo.lock is tracked in git. This is an application, not a library — reproducible builds beat floating dependency resolution. Commit lockfile changes with the change that caused them.

Crate boundaries are the modularity mechanism

Section titled “Crate boundaries are the modularity mechanism”

Dependency direction is strictly leaf to shell, and it is a compile-time invariant rather than a guideline:

ds-common ──► ds-core ──► ds-analytics ─┐
│ │
├──► ds-ui ├──► bin/demostatics (the shell)
│ │
ds-proto ──► ds-transport ──► ds-stream ┘

ds-core depends on ds-common and serde, and nothing else. ds-analytics depends on ds-core. Neither can import UI or IO because the dependency edge does not exist — an attempt to do so does not compile. The shell binary is the only crate that depends on everything, and the GUI crates are optional dependencies behind the gui feature.

When you add a crate, decide its layer first. If a leaf crate suddenly needs a transport type, the design is wrong, not the manifest.

TypeScript — demostatics-mobile_application

Section titled “TypeScript — demostatics-mobile_application”

Expo and React Native with expo-router, TanStack Query and zustand. Status: Shipped.

tsconfig.json extends expo/tsconfig.base with "strict": true and the @/* path alias for src/. There is no escape hatch config; keep the code strict-clean.

src/lib/api/client.ts contains the only fetch call in the app. Everything else goes through the request() helper it exports, which handles the base URL, the bearer token, error mapping to ApiError and unauthenticated handling.

src/lib/api/index.ts exposes one function per endpoint, grouped the way demostatics-web/docs/api-v1.md is grouped, and unwraps the data envelope so screens never see it. Paginated lists are the exception: they return Page<T> whole, because meta drives infinite scroll. Adding an endpoint means adding one function there, not a fetch in a component.

src/lib/api/types.ts mirrors the server’s JSON contract. Nothing generates it — no codegen step exists in either repository. Its own header says so:

These types are hand-kept in step with demostatics-web/docs/api-v1.md and the App\Http\Resources classes that produce them. When a resource changes on the server, change it here in the same pass — nothing generates these.

Treat that as binding. A change to a Laravel resource that does not update types.ts in the same pass has created a silent contract drift that tsc cannot catch, because the types will still be internally consistent and simply wrong.

Server state lives in React Query. Only session state lives in zustand, and src/stores/session.ts is the only store. Do not cache a server response in a zustand store.

Every cache key is declared in the keys object in src/lib/query.ts, with hierarchical prefixes so a mutation can invalidate an area (['posts']) without enumerating filter variants. Adding a query means adding its key there; inline key arrays defeat invalidation.

Run all four before you push:

Terminal window
npm test # jest
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm run build:web # expo export --platform web

The last one catches bundler-level breakage that neither the type checker nor the test run sees. All four are declared in package.json. Nothing enforces them — this repository has no CI, so the gate is yours to run.

This section exists because of one bug worth not repeating.

The desktop’s Feature enum in crates/ds-core/src/identity.rs originally derived Serialize and Deserialize. The derive emits the Rust variant name, "RiskViews", while demostatics-pc_application/docs/API_CONTRACT.md specifies the kebab-case "risk-views". Nothing broke, because both ends of the wire were the same Rust enum and therefore agreed with each other. The disagreement would have surfaced the first time a non-Rust server sent a Welcome frame — which is exactly when it would have been most expensive to find.

Two rules came out of it.

Hand-write serde for anything that crosses a language boundary. Feature now has explicit Serialize and Deserialize impls delegating to name() and from_name(), so the wire strings are written down once, in code, and are testable:

impl Serialize for Feature {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}

Make enumerations open. A derived Deserialize rejects an unknown variant, and rejecting one feature name fails the entire Welcome payload — so a single capability added on the server would lock every already-shipped desktop out of its session until its user updated. from_name() maps anything unrecognised to Feature::Unknown instead, and Entitlements::can() answers false for Unknown unconditionally:

pub fn can(&self, feature: Feature) -> bool {
feature != Feature::Unknown && self.features.contains(&feature)
}

Unknown degrades to never-granted, never to granted and never to a failed payload. Apply both rules to any new enumeration that appears in a frame, a JSON API field or a stored document.

The rules for this site, in short. The full authoring guide is the docs site page.

  • Frontmatter is exactly title and description, in that order.
  • No H1 in the body. Starlight renders the frontmatter title as the H1.
  • Sections are ##, subsections are ###. Never deeper.
  • Never hand-write an in-page anchor list. Starlight generates the right-hand table of contents; hand-written ones on this site are already broken.
  • Only four aside types exist: note, tip, caution, danger. Any other name renders as literal text.
  • Mermaid does not render here. Use ASCII inside a fenced text block.
  • Cross-links are root-relative with a trailing slash and no extension: /architecture/system-map/.
  • Refer to source files by repo-relative path in inline code, never an absolute path.

Every capability described anywhere on this site carries one of four markers, and only these four:

MarkerMeaning
ShippedCode exists and runs today
PartialSome of it works, named gaps remain
PlannedDocumented and decided, no code
Not builtReferenced by other documents, but nothing exists

If you cannot determine the status from the source files, say that on the page. Never present a plan as a fact, and never invent a version number, endpoint, command, table name, file path or metric.

The code wins. Fix the document in the same change that revealed the disagreement — not in a follow-up issue, which is how the current backlog was built.

Two live examples, both in demostatics-web:

  • The README lists content-creation rate limits as living in routes/web.php (throttle:20,1 on post and comment routes). Grep routes/web.php for throttle and you get nothing. Auth throttles are real, in routes/auth.php and routes/api.php; content creation is unthrottled.
  • routes/api.php states in its own header comment that API access tiers “mirror the web routes exactly”. The geo cascade and the subcategory lookup are public on the API while their web equivalents sit behind auth and verification.

Neither is difficult to fix. Both survived because the code changed and the prose next to it did not.

The full register of documents that lost this race — thirteen contradictions, plus dead and orphaned code — is open questions. Read it before you trust a claim in any README on this platform, including the ones cited on this page.