This commit is contained in:
2026-04-09 14:55:37 +02:00
parent 8be455ba98
commit 4a85efc270
30 changed files with 4895 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
target/
build/
+27 -1
View File
@@ -1,3 +1,29 @@
# Hermes # Hermes
Hermes is the internal name of this project Hermes is a native iOS and Android prototype for a sports-betting research study.
The app is designed to feel premium, fast, and clear while keeping the server
authoritative for timing, odds, settlement, and analytics.
## Goals
- Native UI on iOS and Android
- English and Swedish from day one
- Research-first flow, not real-money betting
- Structured analytics and audit logging
- Stable backend and API contracts for iteration
## Repo Layout
- `backend/` Rust API and migrations
- `contracts/` OpenAPI and localization contracts
- `docs/` architecture, schema, and state-machine docs
- `infra/` local environment and deployment assets
- `mobile/` native app scaffolds
- `fixtures/` sample media and test data
- `scripts/` helper automation
## Status
The repository has been initialized with the first planning and backend
foundation artifacts. The remaining work is to fill out the domain modules and
native clients.
+3014
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "hermes-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["json"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
config = "0.14"
redis = "0.28"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "uuid", "chrono"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
tower = { version = "0.5", features = ["make"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["serde", "v4"] }
validator = { version = "0.19", features = ["derive"] }
[dev-dependencies]
@@ -0,0 +1,18 @@
DROP TABLE IF EXISTS audit_log_attributes;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS analytics_event_attributes;
DROP TABLE IF EXISTS analytics_events;
DROP TABLE IF EXISTS analytics_event_types;
DROP TABLE IF EXISTS localization_values;
DROP TABLE IF EXISTS localization_keys;
DROP TABLE IF EXISTS experiment_assignments;
DROP TABLE IF EXISTS settlements;
DROP TABLE IF EXISTS bet_intents;
DROP TABLE IF EXISTS outcome_odds;
DROP TABLE IF EXISTS odds_versions;
DROP TABLE IF EXISTS outcomes;
DROP TABLE IF EXISTS markets;
DROP TABLE IF EXISTS event_media;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,190 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_ref text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
preferred_language text NOT NULL,
device_platform text NOT NULL
);
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
experiment_variant text NOT NULL,
app_version text NOT NULL,
device_model text,
os_version text,
locale_code text NOT NULL
);
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sport_type text NOT NULL,
source_ref text NOT NULL UNIQUE,
title_en text NOT NULL,
title_sv text NOT NULL,
status text NOT NULL,
preview_start_ms bigint NOT NULL,
preview_end_ms bigint NOT NULL,
reveal_start_ms bigint NOT NULL,
reveal_end_ms bigint NOT NULL,
lock_at timestamptz NOT NULL,
settle_at timestamptz NOT NULL
);
CREATE TABLE event_media (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
media_type text NOT NULL,
hls_master_url text NOT NULL,
poster_url text,
duration_ms bigint NOT NULL,
preview_start_ms bigint NOT NULL,
preview_end_ms bigint NOT NULL,
reveal_start_ms bigint NOT NULL,
reveal_end_ms bigint NOT NULL,
UNIQUE (event_id, media_type)
);
CREATE TABLE markets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
question_key text NOT NULL,
market_type text NOT NULL,
status text NOT NULL,
lock_at timestamptz NOT NULL,
settlement_rule_key text NOT NULL
);
CREATE TABLE outcomes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
outcome_code text NOT NULL,
label_key text NOT NULL,
sort_order integer NOT NULL,
UNIQUE (market_id, outcome_code)
);
CREATE TABLE odds_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
version_no integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
is_current boolean NOT NULL DEFAULT false,
UNIQUE (market_id, version_no)
);
CREATE UNIQUE INDEX odds_versions_current_idx
ON odds_versions (market_id)
WHERE is_current;
CREATE TABLE outcome_odds (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
odds_version_id uuid NOT NULL REFERENCES odds_versions(id) ON DELETE CASCADE,
outcome_id uuid NOT NULL REFERENCES outcomes(id) ON DELETE CASCADE,
decimal_odds numeric(10,4) NOT NULL,
fractional_num integer NOT NULL,
fractional_den integer NOT NULL,
UNIQUE (odds_version_id, outcome_id)
);
CREATE TABLE bet_intents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
session_id uuid NOT NULL REFERENCES sessions(id),
event_id uuid NOT NULL REFERENCES events(id),
market_id uuid NOT NULL REFERENCES markets(id),
outcome_id uuid NOT NULL REFERENCES outcomes(id),
idempotency_key text NOT NULL UNIQUE,
client_sent_at timestamptz NOT NULL,
server_received_at timestamptz NOT NULL DEFAULT now(),
accepted boolean NOT NULL,
acceptance_code text NOT NULL,
accepted_odds_version_id uuid REFERENCES odds_versions(id)
);
CREATE TABLE settlements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
market_id uuid NOT NULL UNIQUE REFERENCES markets(id) ON DELETE CASCADE,
settled_at timestamptz NOT NULL,
winning_outcome_id uuid NOT NULL REFERENCES outcomes(id)
);
CREATE TABLE experiment_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
variant text NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (session_id)
);
CREATE TABLE localization_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key_name text NOT NULL UNIQUE,
description text NOT NULL
);
CREATE TABLE localization_values (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
localization_key_id uuid NOT NULL REFERENCES localization_keys(id) ON DELETE CASCADE,
locale_code text NOT NULL,
text_value text NOT NULL,
UNIQUE (localization_key_id, locale_code)
);
CREATE TABLE analytics_event_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_name text NOT NULL UNIQUE,
description text NOT NULL
);
CREATE TABLE analytics_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
analytics_event_type_id uuid NOT NULL REFERENCES analytics_event_types(id),
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE analytics_event_attributes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
analytics_event_id uuid NOT NULL REFERENCES analytics_events(id) ON DELETE CASCADE,
attribute_key text NOT NULL,
attribute_value text NOT NULL
);
CREATE TABLE audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
actor_type text NOT NULL,
actor_id uuid,
action_name text NOT NULL,
target_type text NOT NULL,
target_id uuid,
trace_id text NOT NULL,
note text
);
CREATE TABLE audit_log_attributes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
audit_log_id uuid NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE,
attribute_key text NOT NULL,
attribute_value text NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
CREATE INDEX sessions_started_at_idx ON sessions (started_at);
CREATE INDEX events_status_idx ON events (status);
CREATE INDEX markets_event_id_idx ON markets (event_id);
CREATE INDEX markets_lock_at_idx ON markets (lock_at);
CREATE INDEX bet_intents_session_id_idx ON bet_intents (session_id);
CREATE INDEX bet_intents_market_id_idx ON bet_intents (market_id);
CREATE INDEX bet_intents_idempotency_key_idx ON bet_intents (idempotency_key);
CREATE INDEX analytics_events_session_id_idx ON analytics_events (session_id);
CREATE INDEX analytics_events_user_id_idx ON analytics_events (user_id);
CREATE INDEX analytics_events_type_id_idx ON analytics_events (analytics_event_type_id);
CREATE INDEX audit_logs_created_at_idx ON audit_logs (created_at);
+113
View File
@@ -0,0 +1,113 @@
use std::{sync::Arc, time::Instant};
use chrono::{DateTime, Utc};
use redis::Client as RedisClient;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{config::AppConfig, error::AppError};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
pub started_at: Instant,
pub database_pool: Option<PgPool>,
pub redis_client: Option<RedisClient>,
current_user_id: Uuid,
session: Arc<RwLock<Option<SessionSnapshot>>>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SessionStartRequest {
pub locale_code: Option<String>,
pub device_platform: Option<String>,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub app_version: Option<String>,
pub experiment_variant: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session_id: Uuid,
pub user_id: Uuid,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub experiment_variant: String,
pub app_version: String,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub locale_code: String,
pub device_platform: String,
}
impl AppState {
pub fn new(
config: AppConfig,
database_pool: Option<PgPool>,
redis_client: Option<RedisClient>,
) -> Self {
Self {
config,
started_at: Instant::now(),
database_pool,
redis_client,
current_user_id: Uuid::new_v4(),
session: Arc::new(RwLock::new(None)),
}
}
pub async fn start_session(&self, request: SessionStartRequest) -> SessionSnapshot {
let session = SessionSnapshot {
session_id: Uuid::new_v4(),
user_id: self.current_user_id,
started_at: Utc::now(),
ended_at: None,
experiment_variant: request
.experiment_variant
.unwrap_or_else(|| self.config.default_experiment_variant.clone()),
app_version: request
.app_version
.unwrap_or_else(|| self.config.app_version.clone()),
device_model: request.device_model,
os_version: request.os_version,
locale_code: request
.locale_code
.unwrap_or_else(|| self.config.default_locale.clone()),
device_platform: request
.device_platform
.unwrap_or_else(|| self.config.default_device_platform.clone()),
};
*self.session.write().await = Some(session.clone());
session
}
pub async fn current_session(&self) -> Option<SessionSnapshot> {
self.session.read().await.clone()
}
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
let mut guard = self.session.write().await;
let mut session = guard
.clone()
.ok_or_else(|| AppError::not_found("No active session"))?;
session.ended_at = Some(Utc::now());
*guard = Some(session.clone());
Ok(session)
}
pub fn database_ready(&self) -> bool {
self.database_pool.is_some()
}
pub fn redis_ready(&self) -> bool {
self.redis_client.is_some()
}
pub fn uptime_ms(&self) -> u128 {
self.started_at.elapsed().as_millis()
}
}
+82
View File
@@ -0,0 +1,82 @@
use std::net::SocketAddr;
use anyhow::Context;
use serde::Deserialize;
use validator::Validate;
#[derive(Clone, Debug, Deserialize, Validate)]
pub struct AppConfig {
#[validate(length(min = 1))]
pub service_name: String,
#[validate(length(min = 1))]
pub environment: String,
pub bind_addr: SocketAddr,
#[validate(length(min = 1))]
pub app_version: String,
#[validate(length(min = 1))]
pub default_locale: String,
#[validate(length(min = 1))]
pub default_device_platform: String,
#[validate(length(min = 1))]
pub default_experiment_variant: String,
#[validate(length(min = 1))]
pub log_level: String,
pub stream_base_url: Option<String>,
pub cdn_base_url: Option<String>,
#[validate(length(min = 1))]
pub analytics_mode: String,
#[validate(length(min = 1))]
pub experiment_config: String,
#[validate(length(min = 1))]
pub localization_source: String,
pub database_url: Option<String>,
pub redis_url: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
service_name: "hermes-backend".to_string(),
environment: "local".to_string(),
bind_addr: "127.0.0.1:3000".parse().expect("valid bind address"),
app_version: "0.1.0-dev".to_string(),
default_locale: "en".to_string(),
default_device_platform: "ios".to_string(),
default_experiment_variant: "control".to_string(),
log_level: "info".to_string(),
stream_base_url: None,
cdn_base_url: None,
analytics_mode: "relational".to_string(),
experiment_config: "study-default".to_string(),
localization_source: "contracts/localization".to_string(),
database_url: None,
redis_url: None,
}
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
let settings = config::Config::builder()
.set_default("service_name", "hermes-backend")?
.set_default("environment", "local")?
.set_default("bind_addr", "127.0.0.1:3000")?
.set_default("app_version", "0.1.0-dev")?
.set_default("default_locale", "en")?
.set_default("default_device_platform", "ios")?
.set_default("default_experiment_variant", "control")?
.set_default("log_level", "info")?
.set_default("analytics_mode", "relational")?
.set_default("experiment_config", "study-default")?
.set_default("localization_source", "contracts/localization")?
.add_source(config::Environment::with_prefix("HERMES").separator("__"))
.build()
.context("failed to load Hermes configuration")?;
let config: Self = settings
.try_deserialize()
.context("failed to deserialize Hermes configuration")?;
config.validate().context("invalid Hermes configuration")?;
Ok(config)
}
}
+29
View File
@@ -0,0 +1,29 @@
use std::time::Duration;
use anyhow::Context;
use redis::Client as RedisClient;
use sqlx::{postgres::PgPoolOptions, PgPool};
pub async fn connect_postgres(database_url: Option<&str>) -> anyhow::Result<Option<PgPool>> {
match database_url {
Some(url) if !url.trim().is_empty() => {
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await
.with_context(|| format!("failed to connect to postgres at {url}"))?;
Ok(Some(pool))
}
_ => Ok(None),
}
}
pub fn connect_redis(redis_url: Option<&str>) -> anyhow::Result<Option<RedisClient>> {
match redis_url {
Some(url) if !url.trim().is_empty() => {
Ok(Some(RedisClient::open(url).context("failed to open redis client")?))
}
_ => Ok(None),
}
}
+69
View File
@@ -0,0 +1,69 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("{0}")]
ServiceUnavailable(String),
#[error("{0}")]
Internal(String),
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
code: &'static str,
message: String,
}
impl AppError {
pub fn bad_request(message: impl Into<String>) -> Self {
Self::BadRequest(message.into())
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
fn status_code(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::NotFound(_) => "not_found",
Self::Conflict(_) => "conflict",
Self::ServiceUnavailable(_) => "service_unavailable",
Self::Internal(_) => "internal_error",
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
let body = Json(ErrorResponse {
code: self.code(),
message: self.to_string(),
});
(status, body).into_response()
}
}
+43
View File
@@ -0,0 +1,43 @@
#![forbid(unsafe_code)]
pub mod app_state;
pub mod config;
pub mod db;
pub mod error;
pub mod routes;
pub mod telemetry;
use axum::Router;
use axum::Extension;
use tower_http::trace::TraceLayer;
use crate::{app_state::AppState, config::AppConfig};
pub fn build_router(state: AppState) -> Router {
routes::router()
.layer(Extension(state))
.layer(TraceLayer::new_for_http())
}
pub async fn run() -> anyhow::Result<()> {
let config = AppConfig::load()?;
telemetry::init(&config.log_level);
tracing::info!(
service = %config.service_name,
environment = %config.environment,
version = %config.app_version,
"starting Hermes backend"
);
let database_pool = db::connect_postgres(config.database_url.as_deref()).await?;
let redis_client = db::connect_redis(config.redis_url.as_deref())?;
let state = AppState::new(config.clone(), database_pool, redis_client);
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(config.bind_addr).await?;
tracing::info!(addr = %config.bind_addr, "listening");
axum::serve(listener, app).await?;
Ok(())
}
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
hermes_backend::run().await
}
+27
View File
@@ -0,0 +1,27 @@
use axum::{extract::Extension, Json};
use serde::Serialize;
use crate::app_state::AppState;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub service_name: String,
pub environment: String,
pub version: String,
pub uptime_ms: u128,
pub database_ready: bool,
pub redis_ready: bool,
}
pub async fn handler(Extension(state): Extension<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service_name: state.config.service_name.clone(),
environment: state.config.environment.clone(),
version: state.config.app_version.clone(),
uptime_ms: state.uptime_ms(),
database_ready: state.database_ready(),
redis_ready: state.redis_ready(),
})
}
+12
View File
@@ -0,0 +1,12 @@
pub mod health;
pub mod session;
use axum::{routing::{get, post}, Router};
pub fn router() -> Router {
Router::new()
.route("/health", get(health::handler))
.route("/api/v1/session/start", post(session::start))
.route("/api/v1/session/end", post(session::end))
.route("/api/v1/session/me", get(session::me))
}
+24
View File
@@ -0,0 +1,24 @@
use axum::{extract::Extension, http::StatusCode, Json};
use crate::{app_state::{AppState, SessionSnapshot, SessionStartRequest}, error::AppError};
pub async fn start(
Extension(state): Extension<AppState>,
Json(payload): Json<SessionStartRequest>,
) -> Result<(StatusCode, Json<SessionSnapshot>), AppError> {
let session = state.start_session(payload).await;
Ok((StatusCode::CREATED, Json(session)))
}
pub async fn end(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
let session = state.end_session().await?;
Ok(Json(session))
}
pub async fn me(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
let session = state
.current_session()
.await
.ok_or_else(|| AppError::not_found("No active session"))?;
Ok(Json(session))
}
+15
View File
@@ -0,0 +1,15 @@
use tracing_subscriber::EnvFilter;
pub fn init(filter: &str) {
let filter = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.compact()
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("failed to install tracing subscriber");
}
+46
View File
@@ -0,0 +1,46 @@
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
use tower::ServiceExt;
#[tokio::test]
async fn health_returns_ok() {
let app = build_router(AppState::new(AppConfig::default(), None, None));
let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn session_start_and_me_work() {
let app = build_router(AppState::new(AppConfig::default(), None, None));
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/session/start")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["locale_code"], "en");
let response = app
.oneshot(Request::builder().uri("/api/v1/session/me").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
+40
View File
@@ -0,0 +1,40 @@
{
"app.name": "Hermes",
"common.continue": "Continue",
"common.cancel": "Cancel",
"common.close": "Close",
"common.retry": "Retry",
"common.loading": "Loading",
"common.error_title": "Something went wrong",
"common.ok": "OK",
"onboarding.title": "Study intro",
"onboarding.subtitle": "Watch the clip, make your choice before lock, then see the reveal.",
"onboarding.consent_title": "Consent",
"onboarding.consent_body": "This prototype is for research and does not use real money.",
"onboarding.language_title": "Choose language",
"onboarding.start_session": "Start session",
"feed.next_round_title": "Next round",
"feed.next_round_body": "A new clip is ready for review.",
"feed.watch_preview": "Watch preview",
"feed.round_ready": "Round ready",
"round.countdown_label": "Lock in",
"round.locked_label": "Locked",
"round.selection_prompt": "Choose an outcome before lock.",
"round.selection_confirmed": "Selection accepted",
"round.selection_submitting": "Submitting selection",
"round.odds_label": "Odds",
"reveal.title": "Reveal",
"reveal.subtitle": "See what happened next.",
"result.title": "Result",
"result.user_selection": "Your selection",
"result.outcome": "Outcome",
"result.next_round": "Next round",
"settings.title": "Settings",
"settings.language": "Language",
"settings.haptics": "Haptics",
"settings.analytics": "Analytics",
"errors.generic": "Please try again.",
"errors.network": "Network error. Check your connection.",
"errors.playback": "Video playback failed.",
"errors.session_expired": "Session expired. Please start again."
}
+40
View File
@@ -0,0 +1,40 @@
{
"app.name": "Hermes",
"common.continue": "Fortsätt",
"common.cancel": "Avbryt",
"common.close": "Stäng",
"common.retry": "Försök igen",
"common.loading": "Laddar",
"common.error_title": "Något gick fel",
"common.ok": "OK",
"onboarding.title": "Studieintro",
"onboarding.subtitle": "Titta på klippet, gör ditt val före låsning och se sedan avslöjandet.",
"onboarding.consent_title": "Samtycke",
"onboarding.consent_body": "Denna prototyp är för forskning och använder inte riktiga pengar.",
"onboarding.language_title": "Välj språk",
"onboarding.start_session": "Starta session",
"feed.next_round_title": "Nästa runda",
"feed.next_round_body": "Ett nytt klipp är klart för granskning.",
"feed.watch_preview": "Titta på förhandsklipp",
"feed.round_ready": "Rundan är klar",
"round.countdown_label": "Låsning om",
"round.locked_label": "Låst",
"round.selection_prompt": "Välj ett utfall före låsning.",
"round.selection_confirmed": "Valet accepterat",
"round.selection_submitting": "Skickar val",
"round.odds_label": "Odds",
"reveal.title": "Avslöjande",
"reveal.subtitle": "Se vad som hände sedan.",
"result.title": "Resultat",
"result.user_selection": "Ditt val",
"result.outcome": "Utfall",
"result.next_round": "Nästa runda",
"settings.title": "Inställningar",
"settings.language": "Språk",
"settings.haptics": "Haptik",
"settings.analytics": "Analys",
"errors.generic": "Försök igen.",
"errors.network": "Nätverksfel. Kontrollera anslutningen.",
"errors.playback": "Videouppspelningen misslyckades.",
"errors.session_expired": "Sessionen har gått ut. Starta igen."
}
+584
View File
@@ -0,0 +1,584 @@
openapi: 3.1.0
info:
title: Hermes API
version: 0.1.0
description: Native betting study prototype API.
servers:
- url: http://localhost:3000
paths:
/health:
get:
summary: Health check
responses:
'200':
description: Service health
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
/api/v1/session/start:
post:
summary: Start a session
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SessionStartRequest'
responses:
'201':
description: Session started
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
/api/v1/session/end:
post:
summary: End the current session
responses:
'200':
description: Session ended
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
/api/v1/session/me:
get:
summary: Inspect the current session
responses:
'200':
description: Current session snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
/api/v1/feed/next:
get:
summary: Fetch the next round
responses:
'200':
description: Next event payload
content:
application/json:
schema:
$ref: '#/components/schemas/Event'
/api/v1/events/{event_id}:
get:
summary: Fetch an event
parameters:
- name: event_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Event
content:
application/json:
schema:
$ref: '#/components/schemas/Event'
/api/v1/events/{event_id}/manifest:
get:
summary: Fetch the event manifest
parameters:
- name: event_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Manifest
content:
application/json:
schema:
$ref: '#/components/schemas/EventManifest'
/api/v1/events/{event_id}/markets:
get:
summary: List markets for an event
parameters:
- name: event_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Markets
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Market'
/api/v1/markets/{market_id}/odds/current:
get:
summary: Fetch current odds for a market
parameters:
- name: market_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Current odds
content:
application/json:
schema:
$ref: '#/components/schemas/OddsVersion'
/api/v1/stream:
get:
summary: Live odds and state stream
responses:
'200':
description: Server-sent events stream
content:
text/event-stream:
schema:
type: string
/api/v1/bets/intent:
post:
summary: Submit a bet intent
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BetIntentRequest'
responses:
'201':
description: Bet intent result
content:
application/json:
schema:
$ref: '#/components/schemas/BetIntentResponse'
/api/v1/bets/{bet_intent_id}:
get:
summary: Fetch a bet intent
parameters:
- name: bet_intent_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Bet intent
content:
application/json:
schema:
$ref: '#/components/schemas/BetIntentResponse'
/api/v1/events/{event_id}/result:
get:
summary: Fetch event result
parameters:
- name: event_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Result payload
content:
application/json:
schema:
$ref: '#/components/schemas/Settlement'
/api/v1/analytics/batch:
post:
summary: Ingest analytics batch
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsBatchRequest'
responses:
'202':
description: Accepted
/api/v1/experiments/config:
get:
summary: Fetch experiment config
responses:
'200':
description: Experiment config
content:
application/json:
schema:
$ref: '#/components/schemas/ExperimentConfig'
/api/v1/localization/{locale_code}:
get:
summary: Fetch a localization bundle
parameters:
- name: locale_code
in: path
required: true
schema:
type: string
enum: [en, sv]
responses:
'200':
description: Localization bundle
content:
application/json:
schema:
$ref: '#/components/schemas/LocalizationBundle'
/api/v1/admin/events:
post:
summary: Create an event
responses:
'201':
description: Created
/api/v1/admin/markets:
post:
summary: Create a market
responses:
'201':
description: Created
/api/v1/admin/odds:
post:
summary: Publish odds
responses:
'201':
description: Created
/api/v1/admin/settlements:
post:
summary: Publish a settlement
responses:
'201':
description: Created
components:
schemas:
HealthResponse:
type: object
required: [status, service_name, environment, version, uptime_ms, database_ready, redis_ready]
properties:
status:
type: string
service_name:
type: string
environment:
type: string
version:
type: string
uptime_ms:
type: integer
database_ready:
type: boolean
redis_ready:
type: boolean
SessionStartRequest:
type: object
properties:
locale_code:
type: string
device_platform:
type: string
device_model:
type: string
os_version:
type: string
app_version:
type: string
SessionResponse:
type: object
required: [session_id, user_id, started_at, experiment_variant, app_version, locale_code, device_platform]
properties:
session_id:
type: string
format: uuid
user_id:
type: string
format: uuid
started_at:
type: string
format: date-time
ended_at:
type: string
format: date-time
nullable: true
experiment_variant:
type: string
app_version:
type: string
device_model:
type: string
nullable: true
os_version:
type: string
nullable: true
locale_code:
type: string
device_platform:
type: string
Event:
type: object
required: [id, sport_type, source_ref, title_en, title_sv, status, lock_at, settle_at]
properties:
id:
type: string
format: uuid
sport_type:
type: string
source_ref:
type: string
title_en:
type: string
title_sv:
type: string
status:
type: string
preview_start_ms:
type: integer
preview_end_ms:
type: integer
reveal_start_ms:
type: integer
reveal_end_ms:
type: integer
lock_at:
type: string
format: date-time
settle_at:
type: string
format: date-time
EventManifest:
type: object
properties:
event:
$ref: '#/components/schemas/Event'
media:
type: array
items:
$ref: '#/components/schemas/EventMedia'
markets:
type: array
items:
$ref: '#/components/schemas/Market'
EventMedia:
type: object
required: [id, event_id, media_type, hls_master_url, duration_ms]
properties:
id:
type: string
format: uuid
event_id:
type: string
format: uuid
media_type:
type: string
hls_master_url:
type: string
poster_url:
type: string
nullable: true
duration_ms:
type: integer
preview_start_ms:
type: integer
preview_end_ms:
type: integer
reveal_start_ms:
type: integer
reveal_end_ms:
type: integer
Market:
type: object
required: [id, event_id, question_key, market_type, status, lock_at, settlement_rule_key]
properties:
id:
type: string
format: uuid
event_id:
type: string
format: uuid
question_key:
type: string
market_type:
type: string
status:
type: string
lock_at:
type: string
format: date-time
settlement_rule_key:
type: string
outcomes:
type: array
items:
$ref: '#/components/schemas/Outcome'
Outcome:
type: object
required: [id, market_id, outcome_code, label_key, sort_order]
properties:
id:
type: string
format: uuid
market_id:
type: string
format: uuid
outcome_code:
type: string
label_key:
type: string
sort_order:
type: integer
OddsVersion:
type: object
required: [id, market_id, version_no, created_at, is_current]
properties:
id:
type: string
format: uuid
market_id:
type: string
format: uuid
version_no:
type: integer
created_at:
type: string
format: date-time
is_current:
type: boolean
odds:
type: array
items:
$ref: '#/components/schemas/OutcomeOdds'
OutcomeOdds:
type: object
required: [id, odds_version_id, outcome_id, decimal_odds, fractional_num, fractional_den]
properties:
id:
type: string
format: uuid
odds_version_id:
type: string
format: uuid
outcome_id:
type: string
format: uuid
decimal_odds:
type: number
fractional_num:
type: integer
fractional_den:
type: integer
BetIntentRequest:
type: object
required: [session_id, event_id, market_id, outcome_id, idempotency_key, client_sent_at]
properties:
session_id:
type: string
format: uuid
event_id:
type: string
format: uuid
market_id:
type: string
format: uuid
outcome_id:
type: string
format: uuid
idempotency_key:
type: string
client_sent_at:
type: string
format: date-time
BetIntentResponse:
type: object
required: [id, accepted, acceptance_code, server_received_at]
properties:
id:
type: string
format: uuid
accepted:
type: boolean
acceptance_code:
type: string
accepted_odds_version_id:
type: string
format: uuid
nullable: true
server_received_at:
type: string
format: date-time
Settlement:
type: object
required: [id, market_id, settled_at, winning_outcome_id]
properties:
id:
type: string
format: uuid
market_id:
type: string
format: uuid
settled_at:
type: string
format: date-time
winning_outcome_id:
type: string
format: uuid
AnalyticsBatchRequest:
type: object
required: [events]
properties:
events:
type: array
items:
$ref: '#/components/schemas/AnalyticsEventInput'
AnalyticsEventInput:
type: object
required: [event_name, occurred_at]
properties:
event_name:
type: string
occurred_at:
type: string
format: date-time
attributes:
type: array
items:
$ref: '#/components/schemas/AttributeInput'
AttributeInput:
type: object
required: [key, value]
properties:
key:
type: string
value:
type: string
ExperimentConfig:
type: object
properties:
variant:
type: string
feature_flags:
type: object
additionalProperties:
type: boolean
LocalizationBundle:
type: object
required: [locale_code, values]
properties:
locale_code:
type: string
values:
type: object
additionalProperties:
type: string
ErrorResponse:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
+106
View File
@@ -0,0 +1,106 @@
# Analytics Taxonomy
## Rules
- All analytics events are structured
- No JSONB payloads
- Events live in `analytics_events`
- Attributes live in `analytics_event_attributes`
## Event Groups
### Lifecycle
- `app_opened`
- `app_backgrounded`
- `app_closed`
- `session_started`
- `session_ended`
### Onboarding
- `language_selected`
- `consent_viewed`
- `consent_accepted`
### Feed and Round Load
- `feed_viewed`
- `round_card_viewed`
- `round_loaded`
- `event_manifest_received`
- `preview_prefetch_started`
- `preview_prefetch_completed`
### Playback
- `preview_started`
- `preview_paused`
- `preview_resumed`
- `preview_completed`
- `reveal_started`
- `reveal_completed`
- `playback_error`
- `stream_reconnected`
### Timing and Odds
- `countdown_visible`
- `countdown_warning_threshold_hit`
- `odds_panel_viewed`
- `odds_version_received`
- `odds_changed`
### Selection and Settlement
- `outcome_focused`
- `outcome_selected`
- `selection_submitted`
- `selection_accepted`
- `selection_rejected`
- `duplicate_selection_attempt`
- `market_locked`
- `result_viewed`
- `next_round_requested`
### UI and Input
- `screen_viewed`
- `cta_pressed`
- `gesture_swipe`
- `gesture_tap`
- `gesture_cancelled`
- `haptic_triggered`
### Localization and Errors
- `localization_bundle_loaded`
- `locale_changed`
- `network_error`
## Common Attributes
- `screen_name`
- `event_id`
- `market_id`
- `outcome_id`
- `odds_version_id`
- `countdown_ms_remaining`
- `locale_code`
- `experiment_variant`
- `network_type`
- `device_orientation`
- `playback_position_ms`
- `latency_ms`
## Research Metrics
- Decision latency
- Round completion rate
- Missed rounds
- Post-lock selection attempts
- Replay desire
- Exit rate
- Control vs modern comparison
- Locale effects
- Device platform effects
+50
View File
@@ -0,0 +1,50 @@
# Architecture
## System Shape
Hermes uses three layers:
- Native client apps for iOS and Android
- A Rust backend API that owns timing and state
- PostgreSQL plus Valkey for durable and short-lived data
## Client Responsibilities
- Render the study experience
- Play preview and reveal video
- Handle gestures and haptics
- Show odds and lock timing
- Keep local session state
- Prefetch media
- Sync clocks with the server
- Capture structured analytics
- Load English and Swedish strings from localization assets
## Backend Responsibilities
- Auth and session binding
- Event feed and manifests
- Markets and odds distribution
- Bet intent validation and acceptance
- Settlement and audit logging
- Experiment assignment
- Localization bundle serving
- Analytics ingestion
- Admin fixture publishing
## Core Rules
- The server decides lock time and settlement
- Clients send intent, not fairness decisions
- Video playback must not block overlay updates
- Client and server state machines must match
- Localization is mandatory for every user-facing string
- Analytics and audit data stay relational
## Repo Order
1. Documents and contracts
2. Backend foundation
3. Domain modules and API coverage
4. Fixture and admin workflows
5. Native iOS and Android apps
+52
View File
@@ -0,0 +1,52 @@
# Localization Catalog
## Rules
- Every user-facing string must be localizable
- Stable key names only
- English is the fallback locale
- Swedish must be present at launch
- Keys are mirrored in `contracts/localization/en.json` and `contracts/localization/sv.json`
## Current Keys
| Key | Purpose |
| --- | --- |
| `app.name` | App name |
| `common.continue` | Continue action |
| `common.cancel` | Cancel action |
| `common.close` | Close action |
| `common.retry` | Retry action |
| `common.loading` | Loading state |
| `common.error_title` | Error title |
| `common.ok` | OK action |
| `onboarding.title` | Study intro title |
| `onboarding.subtitle` | Study intro body |
| `onboarding.consent_title` | Consent title |
| `onboarding.consent_body` | Consent body |
| `onboarding.language_title` | Language picker title |
| `onboarding.start_session` | Start session CTA |
| `feed.next_round_title` | Next round title |
| `feed.next_round_body` | Next round body |
| `feed.watch_preview` | Watch preview CTA |
| `feed.round_ready` | Round ready label |
| `round.countdown_label` | Countdown label |
| `round.locked_label` | Locked label |
| `round.selection_prompt` | Selection prompt |
| `round.selection_confirmed` | Selection accepted |
| `round.selection_submitting` | Selection submitting |
| `round.odds_label` | Odds label |
| `reveal.title` | Reveal title |
| `reveal.subtitle` | Reveal subtitle |
| `result.title` | Result title |
| `result.user_selection` | User selection label |
| `result.outcome` | Outcome label |
| `result.next_round` | Next round CTA |
| `settings.title` | Settings title |
| `settings.language` | Language setting |
| `settings.haptics` | Haptics setting |
| `settings.analytics` | Analytics setting |
| `errors.generic` | Generic error copy |
| `errors.network` | Network error copy |
| `errors.playback` | Playback error copy |
| `errors.session_expired` | Session expired copy |
+192
View File
@@ -0,0 +1,192 @@
# Relational Schema
## Rules
- UUID primary keys
- `timestamptz` for all timestamps
- No JSONB columns
- Child tables for attributes and event payloads
- Foreign keys wherever possible
## Core Tables
### users
- `id`
- `external_ref`
- `created_at`
- `preferred_language`
- `device_platform`
### sessions
- `id`
- `user_id`
- `started_at`
- `ended_at`
- `experiment_variant`
- `app_version`
- `device_model`
- `os_version`
- `locale_code`
### events
- `id`
- `sport_type`
- `source_ref`
- `title_en`
- `title_sv`
- `status`
- `preview_start_ms`
- `preview_end_ms`
- `reveal_start_ms`
- `reveal_end_ms`
- `lock_at`
- `settle_at`
### event_media
- `id`
- `event_id`
- `media_type`
- `hls_master_url`
- `poster_url`
- `duration_ms`
- `preview_start_ms`
- `preview_end_ms`
- `reveal_start_ms`
- `reveal_end_ms`
### markets
- `id`
- `event_id`
- `question_key`
- `market_type`
- `status`
- `lock_at`
- `settlement_rule_key`
### outcomes
- `id`
- `market_id`
- `outcome_code`
- `label_key`
- `sort_order`
### odds_versions
- `id`
- `market_id`
- `version_no`
- `created_at`
- `is_current`
### outcome_odds
- `id`
- `odds_version_id`
- `outcome_id`
- `decimal_odds`
- `fractional_num`
- `fractional_den`
### bet_intents
- `id`
- `user_id`
- `session_id`
- `event_id`
- `market_id`
- `outcome_id`
- `idempotency_key`
- `client_sent_at`
- `server_received_at`
- `accepted`
- `acceptance_code`
- `accepted_odds_version_id`
### settlements
- `id`
- `market_id`
- `settled_at`
- `winning_outcome_id`
### experiment_assignments
- `id`
- `user_id`
- `session_id`
- `variant`
- `assigned_at`
### localization_keys
- `id`
- `key_name`
- `description`
### localization_values
- `id`
- `localization_key_id`
- `locale_code`
- `text_value`
### analytics_event_types
- `id`
- `event_name`
- `description`
### analytics_events
- `id`
- `analytics_event_type_id`
- `session_id`
- `user_id`
- `occurred_at`
### analytics_event_attributes
- `id`
- `analytics_event_id`
- `attribute_key`
- `attribute_value`
### audit_logs
- `id`
- `created_at`
- `actor_type`
- `actor_id`
- `action_name`
- `target_type`
- `target_id`
- `trace_id`
- `note`
### audit_log_attributes
- `id`
- `audit_log_id`
- `attribute_key`
- `attribute_value`
## Indexes
- `sessions.user_id`
- `sessions.started_at`
- `events.status`
- `markets.event_id`
- `markets.lock_at`
- `bet_intents.session_id`
- `bet_intents.market_id`
- `bet_intents.idempotency_key`
- `analytics_events.session_id`
- `analytics_events.user_id`
- `analytics_events.analytics_event_type_id`
- `audit_logs.created_at`
+31
View File
@@ -0,0 +1,31 @@
# State Machines
## Event Lifecycle
`scheduled -> prefetch_ready -> preview_open -> locking -> locked -> reveal_open -> settled -> archived`
## Client Round State
`idle -> prefetching -> ready -> preview_playing -> selection_pending -> selection_submitting -> selection_accepted -> locked -> reveal_playing -> result_visible -> transitioning -> error`
## Bet Acceptance State
`received -> validated -> accepted`
Rejection states:
- `rejected_too_late`
- `rejected_invalid_market`
- `rejected_invalid_session`
- `rejected_duplicate`
Terminal state:
- `settled`
## Notes
- Locking is server authoritative
- Clients may display synced countdowns, but acceptance is decided by the server
- Selection confirmation must be visually unambiguous
- The locked state freezes the odds display for the user
+9
View File
@@ -0,0 +1,9 @@
# Fixtures
Reserved for sample videos, manifests, and test data.
Planned subdirectories:
- `videos/`
- `manifests/`
- `test-data/`
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: hermes
POSTGRES_USER: hermes
POSTGRES_PASSWORD: hermes
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
valkey:
image: valkey/valkey:8
ports:
- "6379:6379"
volumes:
postgres-data:
+15
View File
@@ -0,0 +1,15 @@
# Android App
Native Jetpack Compose client scaffold for the Hermes study app.
Planned structure:
- `app/`
- `core/`
- `data/`
- `domain/`
- `feature/`
- `benchmark/`
- `androidTest/`
This directory is reserved for the phase 6 native Android implementation.
+13
View File
@@ -0,0 +1,13 @@
# iOS App
Native SwiftUI client scaffold for the Hermes study app.
Planned structure:
- `App/`
- `Core/`
- `Features/`
- `Resources/`
- `Tests/`
This directory is reserved for the phase 5 native iOS implementation.
+3
View File
@@ -0,0 +1,3 @@
# Scripts
Reserved for repo automation and data-loading helpers.