trainlog/docs/architecture.md

971 lines
42 KiB
Markdown

# Architecture
## Synchronization transport boundary
Generation capture, validation, causal import, publication and ACK acceptance
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
probes the expected MTP peer before preferring USB. A successful USB run is
committed independently, then mirrored to Drive best-effort; Drive failure can
never reopen its SQLite transactions. If MTP is unavailable, the same worker
runs over the Drive byte adapter.
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
SyncScreen, the visible USB foreground service, and the private-folder Drive
worker. One process guard prevents overlap, while generation and ACK tables
remain the restart authority. Transport staging and network/provider waits are
outside SQLite transactions. Neither transport can select or traverse an
application database.
Full-generation synchronization uses the common admission lock and a fixed
coordinator worker. Browser input cannot select commands, paths, timeouts or
capabilities. Peer identity/capabilities come from Android advertisement;
Android participates only during a foreground user action. Typed progress is
bounded and durable, and completion is corroborated by SQLite generation/ACK
records.
## 1. System boundary
Trainlog Core is the product authority and separates capture, durable history,
business logic, transport, and presentation. The TUI, Web, and Android are
sibling adapters with different product responsibilities; none owns a parallel
business model.
```text
Android field client
|
| local SQLite
|
+-- automatic mobile snapshot
|
v
Android shared storage
Documents/Trainlog
|
| direct MTP
v
shared desktop sync engine
/ \
/ \
desktop SQLite directional PC artifacts
| |
v v
Trainlog Core Android
|
+-- desktop TUI
+-- local Web adapter -> browser
```
The desktop SQLite database is the canonical local source of truth and
long-term history. Core owns data, persistence, business logic, calculations,
read models, command services, and versioned contracts. An interface must not
query tables directly to reconstruct its own business rules.
The Web Dashboard and Analyse workspace share `GET /api/v1/analysis`, backed by
one C17 read model. Period filtering, active-Program progress, compatible
exercise metrics, explicit MAX results, primary BODY ZONE exposures, and body
measurement first/last/delta calculations occur in Core. React validates the
bounded response and presents it; it does not load raw history or issue SQL.
Absent values remain null, and the model never estimates physiological or
performance scores.
Android local SQLite is a capture store, not a synchronization format.
## 2. Components
### Android application
Responsibilities:
- exercise catalog entry;
- stable-ID exercise rename/editing;
- canonical body-zone selection, display and descendant filtering;
- workout-session recording;
- performed set entry;
- continuous-activity entry;
- body measurement entry;
- local history/detail;
- automatic mobile snapshot generation;
- PC catalog application;
- synchronization request creation;
- synchronization receipt display.
- retrieval of a session prepared through Trainlog;
- future sensor-data acquisition, including BLE heart rate.
Android is the field adapter for gym use, actual-work capture, feedback, and
returning observations to Trainlog. It is not responsible for canonical
long-term analytics or business truth and never accesses desktop SQLite
directly.
### Desktop core
The C17 core owns:
- data and domain rules;
- desktop SQLite persistence;
- business calculations;
- typed read models and transactional command services;
- versioned contracts;
- exercise/catalog rules;
- generated body-zone taxonomy and relation APIs;
- profile-aware session data;
- body data;
- ID and time helpers;
- USB discovery;
- direct MTP operations;
- the shared bidirectional synchronization engine.
### TUI
The TUI is the administration, inspection, maintenance, import/export, and
technical-tooling adapter. The Notcurses layer owns terminal interaction and
rendering. It is confined to the desktop executable; persistence,
synchronization, and core services have no terminal-library dependency.
It consumes core services for:
- session entry/editing;
- history;
- exercise performance;
- exercise body-zone creation/edit/detail and filters;
- body tracking;
- graphs;
- manual synchronization;
- synchronization log/detail display.
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
sections: Accueil, Séances, Exercices, Statistiques,
Synchronisation and Paramètres. The controller owns route history, focus,
overlays and transient leave guards; rendering owns no persistence, SQLite,
transport, or synchronization decisions. Navigation and redraw alone never
write a database or run synchronization. Session drafts, generator previews,
and transient forms use explicit keep/discard guards; discard of transient TUI
state performs no database write. Sync operations retain their existing
direction confirmation and diagnostic ownership.
The desktop controller has one event loop and consumes Trainlog input semantics
before screen code. Overlay input precedes route aliases, local editors precede
global aliases, and overlay close restores saved focus/stable selection. Its
layout is compact from 72x20, has a sidebar from 100x26, and expands it from
120x32. Its bounded UTF-8 adapter, stable-ID list state, shared action registry
and run-scoped terminal planes keep Notcurses infrastructure separate from
product semantics.
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard
layering violation. `tui/src/dashboard.c` owns its frozen TUI-compatible SQLite
projection; `tui/src/web_dashboard.c` owns the distinct Web aggregate and
composes that projection only where semantics match.
normalization, bounds, ordering, rolling windows, aggregates and legacy-data
flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses
or private TUI type. The TUI calls that service with an explicit reference Unix
second and presents the caller-owned bounded snapshot; it neither includes
`database_internal.h` for the Dashboard nor reproduces its calculations.
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` was completed in the same tranche
because live TUI adoption is the parity proof. This was an incremental
extraction, not a global `tui.c` refactor.
### Local Web application
`TRAINLOG_WEB_V1` is a sibling adapter of the TUI and Android, never an
extension of the TUI. It is launched by either:
```text
trainlog -w
trainlog --web
```
Trainlog serves the Web application and its versioned HTTP API to a browser.
The Web owns analysis, visualization, program preparation, and session
preparation. Initial API introduction is read-only; later program and session
mutations must call explicit transactional Core command services. Generic CRUD
over SQLite tables is forbidden.
The V1 network contract is:
```text
address = 127.0.0.1
default port = 8080
explicit override = trainlog -w --port <port>
IPv6 = out of scope
```
A requested occupied port is an explicit startup failure. Trainlog never
selects another port silently and never binds to a LAN address implicitly.
The architectural boundary is:
```text
Browser
|
| HTTP / independently versioned /api/v1/
v
Web adapter
|
| typed Core services
v
Trainlog Core
|
v
desktop SQLite
```
The following dependencies are forbidden:
```text
Browser -> SQLite
React -> SQL
Web route -> database_internal.h
Web route -> duplicated TUI calculation
```
The Web API version is independent of the SQLite schema,
`TRAINLOG_FORMAT_V1`, mobile/synchronization/export formats, and the Trainlog
product version. Web evolution must not alter a frozen exchange format.
V1 preserves serialized Core access: one process-owned `TrainlogDatabase` is
opened and migrated once before HTTP listening, used without concurrent direct
SQLite-handle access, and closed by the process owner after server shutdown. A
single-threaded event loop is the preferred server architecture.
`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` implements that owner. CLI parsing
precedes all path, database, terminal and HTTP initialization. The common owner
resolves the private XDG database path, opens/migrates it exactly once, dispatches
to either the unchanged TUI or Web adapter, then closes it exactly once.
`--help`, `--version` and CLI errors stop before path or database access.
The HTTP adapter uses GNU libmicrohttpd in external-select mode with
`MHD_USE_NO_THREAD_SAFETY`: libmicrohttpd creates no polling or request thread.
All callbacks, future Core calls and SQLite access therefore run serially on
the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only
`127.0.0.1`; port zero remains unavailable through the CLI. SIGINT/SIGTERM
handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd,
returns to the process owner and lets it close the database.
The read-only technical route remains `GET /api/v1/health`; unknown API routes
return JSON 404 and never fall through to the SPA. The adapter
bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection
memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and
idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active
port, emits explicit JSON content type, `nosniff`, `no-store`, a deny-all CSP
and no permissive CORS header. Assets, telemetry and remote resources are
absent. Before the first mutation route, origin validation and explicit
CSRF protection remain mandatory in addition to these transport bounds.
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN` adds the sibling `web/` React 19,
TypeScript and Vite application. A History API router owned by Trainlog handles
the five fixed shell destinations without another runtime dependency. `/`,
`/analyse`, `/programmes`, `/seances`, and `/exercices` receive the embedded
`index.html`; exact generated asset URLs receive only their manifest bytes.
Unknown assets and traversal attempts are 404. `HEAD` is supported for UI
assets; other asset methods are 405. API dispatch remains separate.
Vite writes only into the Meson build directory. A bounded deterministic
Python generator sorts at most 128 known files, rejects files over 4 MiB or a
manifest over 16 MiB, assigns MIME and SHA-256 ETag metadata, then emits static
C arrays linked into `trainlog`. Runtime serving performs exact URL lookup and
never reads a filesystem path. HTML uses `no-cache`; hash-named Vite assets use
one-year immutable caching. The CSP permits only same-origin scripts, styles,
connections and images, with no `unsafe-inline` or `unsafe-eval`.
The Meson feature option `web=auto|enabled|disabled` separates provisioning
from compilation. Meson never installs npm packages. `auto` embeds assets only
when Node, npm and prepared `web/node_modules` exist; `enabled` makes those
build prerequisites mandatory and is required for releases; `disabled`
produces a desktop-only binary whose Web launch fails explicitly. An embedded
binary requires neither Node/npm nor `web/dist` at runtime.
Every Web page retains one application shell:
- Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices;
- Body: responsive tile-organized page content;
- Footer: user, date of the latest completed session, and all primary BODY
ZONES worked in that completed session.
The Unix login is the V1 user fallback until Trainlog owns an explicit display
name. Footer zones come only from the latest completed session; missing facts
remain unavailable rather than being invented. Header and Footer remain
present during navigation.
The sole V1 theme is Catppuccin Mocha with Lavender as its primary accent.
There is no V1 light theme. The browser is the client and Electron is outside
the architecture. React, TypeScript, Vite, and Apache ECharts are the intended
frontend stack. Node and npm are build-only dependencies; installed Trainlog
must not require either. All production assets are local, built ahead of time,
and incorporated into the `trainlog` binary from the sibling root `web/`
directory. The shell implements semantic Header/Body/Footer landmarks,
keyboard-visible focus, responsive tile scaffolding and reduced-motion rules.
Absent user/session/zone facts remain visibly unavailable. The bounded grid
engine uses `react-grid-layout` 2.2.4 behind a Trainlog-owned model, adapter and
validator. The implemented Progression visualization loads modular ECharts
6.1.0 lazily; the BODY ZONES visualization is an original code-native SVG.
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
1. Prochaine séance;
2. Activité;
3. Progression;
4. Dernière séance;
5. Records / MAX;
6. Répartition musculaire;
7. Cardio / récupération.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` supports drag and drop, horizontal and
vertical resizing, tile-specific minimum/maximum sizes, automatic
reorganization, responsive projection, versioned private layout persistence,
and restoration of the Trainlog default. Dragging and resizing are enabled only by
the explicit `Modifier l'agencement` mode. A larger tile may reveal more
information, but tile size never changes a metric's business definition.
The single persisted source layout is the canonical desktop 12-column layout:
```text
$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json
```
with fallback:
```text
$HOME/.config/trainlog/web/dashboard-layout-v1.json
```
Its separately versioned format is interface configuration, not training data.
It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI
exports, or session synchronization. Small-screen layouts are derived
responsive projections, not a second persisted truth.
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` implements this path as a strict private
0600 JSON preference, atomically replaced without SQLite involvement. GET/PUT/
DELETE `/api/v1/dashboard-layout` expose the canonical Trainlog shape. ETag and
If-Match guard optimistic revisions; mutation additionally requires exact
loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
Invalid files are preserved for diagnosis while GET safely returns the default.
`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` keeps the HTTP boundary singular: `App`
loads and strictly validates one Dashboard snapshot, then supplies that same
immutable business view to the Footer and the grid. Tile components perform
presentation-only selection by logical size and direct sums of the 90 daily
counts. They never reconstruct activity, progression, session, MAX or zone
rules. The activity map encodes active/session facts directly; muscle bar
length is exactly `session_count`, explicitly labelled. Responsive projection
changes density, not the canonical layout or metric semantics.
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` keeps visualization behind the
same typed snapshot. Progression dynamically imports modular ECharts
(`LineChart`, `GridComponent`, `TooltipComponent`, `SVGRenderer`) only in
medium/large sizes. One instance is owned per mounted chart, updated from the
exact comparable series, resized by `ResizeObserver`, and disposed on unmount.
SVG was selected over Canvas because the bounded series has very few elements
and vector sharpness/responsive scaling matter more than high-volume drawing.
All colours are read from the live Catppuccin CSS variables.
The muscle figure is a code-native SVG whose geometry is ported from the
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to
a portrait ratio based on Android's 236dp tall two-panel Home layout.
Canonical localized mappings are: `chest` to
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
bilateral front/rear legs. Aggregate zones
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
remain in the complete text list. Visual levels are a deterministic ratio to
the maximum `session_count` in the current snapshot: zero, up to one third, up
to two thirds, and above two thirds. They communicate comparison only, never
fatigue, recovery, balance, volume, or physiological quality.
`WEB_DASHBOARD_V1=PASS/FROZEN` closes the Dashboard after final review. The
Progression value axis uses explicit deterministic bounds: constant series get
a symmetric margin of at least 1 kg (rounded outward to 0.5 kg), while varying
series get at least 0.5 kg or 10% of their observed span. The lower bound is
never below zero, and every real observation—including legacy zero—is retained.
Large Progression tiles place facts and chart side by side so the complete
domain stays visible without an internal scroll. Dashboard layout persistence,
responsive projections and the seven domain semantics remain unchanged.
`WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
projection; statements are finalized before return. The seven domains mean:
1. **Prochaine séance** is available only for a persisted plan explicitly
accepted for execution. Schema v18 has proposals, targets in occurrences and
completed/observable history, but no such lifecycle gate. V1 therefore
returns `available=false`, reason `no_persisted_executable_plan`; proposals,
templates and generated suggestions never qualify.
2. **Activité** is the 90 local calendar days ending on the backend's current
local date. A day is active when at least one observable session starts on
that persisted timestamp's local date. Observable means at least one
performed set, continuous activity or explicit MAX; `ended_at` is not a
gate. Multiple sessions are counted separately and sets are counted without
converting continuous/MAX facts into fake sets.
3. **Progression** reuses the frozen Core Dashboard selection and exact
`(exercise_id,equipment_id,tracking_mode,load_mode,dose)` identity over 90
days. Points and deterministic improvement events never mix identities.
Frozen legacy present-at-0.0-kg behavior remains unchanged.
4. **Dernière séance** is the newest observable session by represented instant,
then stable session ID. Duration exists only with a valid non-earlier
`ended_at`. Counts distinguish worked occurrences, persisted sets,
continuous occurrences and explicit MAX. Primary zones come only from
occurrences that own one of those facts, deduplicated and ordered by
`zone_id`; catalogue-only exercises never contribute.
5. **Records / MAX** contains at most eight `max_results`, newest first with
session and occurrence IDs as deterministic ties. It exposes the source
exercise/equipment and measured weight; ordinary sets and estimated 1RM
never qualify.
6. **Répartition musculaire** covers the 30 local calendar days ending today.
Each persisted primary zone reports distinct observable sessions, worked
occurrences and actual sets. Continuous and MAX occurrences count as work
occurrences/sessions but add no set. Secondary zones and recovery scores
are excluded.
7. **Cardio / récupération** is unavailable with stable reason
`no_cardio_data_source`; duration, load, feedback and continuous activity
are not cardio proxies.
The same snapshot supplies the Unix account name (bounded `getpwuid_r`
fallback), latest observable session timestamp and that session's worked
primary-zone labels to the Footer. Absence is represented by `null`, empty
collections or stable unavailable reasons, never invented zero-valued facts.
The JSON writer is internal, allocation-free and bounded at 128 KiB; it owns
escaping for quotes, backslashes, controls and UTF-8. Collections are bounded
to 90 activity days, eight MAX entries, 17 BODY ZONES, 128 progression points
and a 4,096-session corruption scan. `meta.partial` reports truncation and
`meta.invalid_data` reports rejected persisted values independently.
V1 security invariants are: loopback-only binding, Host validation, no
permissive CORS, foreign-origin/CSRF protection for mutations, explicit bounds
for headers, bodies, connections and timeouts, local assets only, no CDN, no
implicit telemetry, a Content Security Policy, and orderly `SIGINT`/`SIGTERM`
shutdown.
Android's `AppNavigationController` is the corresponding route owner. Its
Material 3 drawer exposes the same seven roots and derives drawer selection
from the route. It uses local vector resources, a sans-serif hierarchy and
minimum 48 dp actions; it has no runtime icon/parser dependency and accepts a
text fallback where an optional icon font is unavailable. Route transitions
preserve durable drafts and require keep/discard resolution for non-durable
forms or generator previews. The Android shell maps the same semantic color
roles to Material 3 tokens; neither platform treats color as the only state
carrier.
### `trainlog-syncd`
`trainlog-syncd` is a small user-session agent.
### Full-generation application orchestrator
The opt-in full-generation path is owned by `sync_orchestrator.py`, launched as
an owned process by the C Web adapter. It uses the same XDG `sync.lock` as the
legacy engine, never borrows the Web process's SQLite handle, and accepts peer
transport only from a trusted local configuration. The HTTP event loop only
admits work and reads a bounded durable report. V3 remains the default when the
trusted generation configuration is absent.
In `mtp` mode the worker invokes one fixed packaged helper. Its production
callbacks are the existing libudev/libmtp object operations; tests replace only
that typed I/O table. Candidate selection requires one exact stable peer
advertisement and required capabilities. Downloads are bounded and size
checked. Upload retries accept byte-identical objects, and each generation
manifest is published after its member objects. The helper runs inside the
orchestrator-owned process group and cannot be selected or parameterized by an
HTTP request.
It polls for a new Android request. With trusted full-generation configuration
it admits that request to the existing application orchestrator and peer
worker; without that configuration it retains the shared C/V3 compatibility
engine used by the TUI. It does not implement a second synchronization
algorithm.
## 3. Exercise model
Trainlog is metadata-driven:
```text
recording_mode = SETS | CONTINUOUS
tracking_mode = REPS | DURATION
data_fields = SPEED_KMH | DISTANCE_KM
```
Valid model-v1 combinations:
```text
SETS + REPS
SETS + DURATION
CONTINUOUS + DURATION
```
Load mode is session-specific:
```text
none
external
assistance
```
Continuous work is persisted separately from performed sets.
Body-zone semantics are a second independent metadata axis:
```text
exercise -> exercise_body_zones -> canonical body-zone manifest
role = primary | secondary
```
The repository-level `catalog/body-zones-v1.json` is the sole taxonomy source.
Android reads it as an asset and the desktop generates a bounded C
representation at build time. Stable `zone_id` values cross persistence and
synchronization boundaries; translated display names do not. `upper_body` and
`lower_body` are hierarchy groups whose descendant membership is derived at
query time, never persisted redundantly. `full_body` and `core` are autonomous.
An unclassified exercise has no relation at all: a secondary-only state is
invalid at repository, database and exchange boundaries rather than being
silently rendered as unclassified.
## 4. Persistence ownership
### Desktop
Desktop SQLite schema v37 is canonical long-term history in the audited
development source. The additive v37 migration stores absolute Program current
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration
added terminal Program logical deletion and its durable replay, generation and
acknowledgement ledger without deleting imported definitions, derived
preparations or workout facts. Android schema v34 extends the read-only
Programs projection with capability-selected V2 planning state; v24 introduced
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
manual-preparation withdrawal identity without deleting revisions, deliveries
or workout facts. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
one-time backfill copies the linked catalogue mode without changing child rows
or stable identities. The additive v16 -> v17 migration adds deterministic
current-profile ancestry in a 32-record-per-exercise durable chain without
altering those occurrence snapshots. The v14
-> v15 additive step introduces immutable wording-revision tables for the subjective
feedback roots introduced in v14; see [Training feedback](training_feedback.md). Its v9 -> v10
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
be finite `>= 0`; the column already existed and targets/max results retain
their strictly-positive contracts. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion.
The additive v17 -> v18 migration adds a desktop-private AI proposal inbox:
imported headers, ordered target-only entries, an immutable `(aid_ draft_id,
payload digest)` import identity, and a nullable successful-publication cursor.
The cursor drains deterministic batches without deleting replay identity. It is
intentionally not the desktop TUI's
in-progress session state and it never creates actual sets. The separate
Android v17 migration adds a pending AI-proposal collection and permanent
`started`/`deleted` tombstones; the pre-existing
`active_session_draft(id=1)` remains the one capture draft. An explicit start
transaction copies a pending proposal's targets into that singleton and
tombstones the proposal; a delete transaction tombstones it. This keeps replay
idempotent without silently resurrecting user-dismissed work.
The additive v10 -> v11 migration creates direct exercise/body-zone relations
and a private synchronization baseline, then seeds only stable-ID mappings
whose decision evidence is recorded in the manifest. It never rewrites an
exercise, occurrence or history row.
The additive v11 -> v12 migration adds only persistent exercise aliases. An
explicit source-to-canonical merge validates the complete recording profile and
primary BODY ZONE, unions compatible secondary zones, repoints occurrence
foreign keys, and then retires the source catalogue row. Entry IDs, sessions,
sets, continuous activity, MAX, equipment and targets remain unchanged.
Main tables:
```text
exercises
sessions
session_exercises
performed_sets
continuous_activity
max_results
body_observations
custom_equipment
exercise_body_zones
exercise_body_zone_sync
```
### Android
Android has an independent local SQLite schema, v34 in the audited source. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one
durable draft.
Its v9 -> v10 migration adds equivalent exercise/body-zone relations and seeds
the same manifest mappings. User creation and editing replace name/profile and
zone relations in one repository transaction.
It mirrors domain concepts needed for capture, but its schema version is not
coupled to the desktop schema.
Synchronization exchanges domain artifacts rather than database files.
`TrainlogRepository` owns a singleton active-session draft, its ordered exercise
and actual-value children, and raw form text. Compose sends meaningful mutations
to that repository; lifecycle callbacks are not the sole persistence boundary.
Normal navigation never deletes the draft. Home restores the resume affordance
from SQLite after process recreation.
Drafts use separate tables from completed sessions and are never export sources.
Finalization inserts the completed session and deletes the draft in one
transaction; failures retain the draft. Catalog row references preserve draft
identity through existing PC-catalog reconciliation. Missing editing-selection
recovery preserves the raw fields and added exercises with a specific warning.
`TrainlogRepository.editExercise()` owns all Android exercise edits. It changes
the display name and normalized form in the existing catalog row identified by
`exercise_id`; foreign-key ownership consequently preserves completed history
and active drafts. A profile edit is admitted only before that row is referenced
by either completed or active-draft data. Same-ID reconciliation applies name
metadata in place. A different-ID normalized-name collision may merge only for
equal recording/tracking modes, compatible represented invariants, and
`data_fields` masks comparable by inclusion. Desktop ownership wins on both
sides; the richer mask is retained and every occurrence/draft reference moves
transactionally. Otherwise synchronization reports a conflict.
Compose presentation has one fixed `AndroidAppShell` Material 3 `Scaffold`
with a `TopAppBar` above its page-content host. Root routes show `TRAINLOG`;
non-root routes show their route title. Screen navigation and data ownership
remain independent from this shell.
## 5. Compatibility boundaries
### Session generator V1
`SESSION_GENERATOR_V1=PASS`. `catalog/session-generation-policy-v1.json` is its sole authored
policy source; generated C data and Android asset loading consume that same
policy. The generator composes read-only runtime/knowledge context, complete
history, deterministic selection and explicit uncertainty into an in-memory
proposal. Preview writes nothing. Android acceptance creates the normal
singleton draft atomically; TUI acceptance enters the normal editor. There is
no generated-session persistence silo.
Exposure counts actual positive-repetition completed SETS+REPS rows only.
The exclusive thresholds are primary/secondary 1/3 at 24 hours and 6/12 at 72
hours; primary produces `warning`, secondary-only produces `notice`, and `none`
is available only after successful analysis. Invalid stored time fails analysis;
these signals do not estimate recovery. Selection is bounded to six exercises,
uses requested-zone/group coverage, diversity, compatible equipment, exclusions,
preferences, recency and deterministic ID ties, and returns shortages rather
than invented candidates. Numeric weight is only an unchanged minimum observed
qualifying actual external-load dose for the exact exercise/equipment within
28 days; MAX, assistance and unknown context do not prescribe a number.
### Frozen Trainlog JSON v1
`TRAINLOG_FORMAT_V1` is frozen and remains a compatibility boundary for its
existing set-based session contract.
### Synchronization artifacts
Synchronization uses separate formats:
```text
trainlog-mobile-export v1
trainlog-mobile-export v2
trainlog-mobile-export v3
trainlog-pc-catalog v1
trainlog-equipment-associations v2
trainlog-equipment-definitions v1
trainlog-exercise-body-zones v1
trainlog-sync-request v1
trainlog-sync-receipt v1
```
A new domain requirement must not be forced into frozen v1 by using notes,
synthetic sets, or data loss.
The active occurrence-aware session exchange is
`trainlog-mobile-export` v3. V2 remains a readable historical artifact. The separate directional
`trainlog-equipment-definitions` v1 artifacts carry user-created equipment
definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from
Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC
to Android. The filename identifies direction; the JSON format and version do
not change. V1 historical artifacts remain readable under their frozen
contracts.
`trainlog-exercise-body-zones-v1.json` is the one direction-neutral zone
companion in both directions. It carries only `exercise_id`, a nullable
`primary_zone_id` and `secondary_zone_ids`; taxonomy definitions stay in the
canonical manifest.
## 6. Direct MTP transport
Linux transport:
```text
physical Android USB device
|
v
libudev discovery
|
| bus + device number
v
libmtp exact raw-device access
|
v
Android internal storage
```
No GVFS/FUSE mount is required.
Canonical exchange directory:
```text
Documents/Trainlog
```
## 7. Shared synchronization engine
Both user-trigger paths call:
```text
trainlog_sync_run()
```
Manual path:
```text
TUI -> trainlog_sync_run()
```
Android-triggered path:
```text
Android request
-> trainlog-syncd
-> trainlog_sync_run()
-> receipt
```
The same engine owns all supported directions. Its explicit modes are:
```text
a = Android -> PC only
p = PC -> Android only
b = Android -> PC, then PC -> Android
```
The Android-to-PC direction receives the mobile equipment-definitions v1
artifact, mobile-export v3, and equipment-associations v2. The PC-to-Android
direction publishes PC equipment-definitions v1 before dependent artifacts,
then publishes the PC catalog v1, PC mobile-export v3 (including completed
sessions and body observations), and equipment-associations v2.
Both directions also transfer the same body-zone companion after exercise
definitions are established and before completion of the direction.
The engine performs the applicable direction steps:
```text
1. direct-MTP device/storage discovery
2. exchange-folder resolution
3. definition artifact transfer and reconciliation before dependent V2 data
4. strict transactional Android -> PC import and body-zone reconciliation when selected
5. PC catalog, body-zone, mobile/body, and association export when selected
6. direct-MTP publication of the selected PC -> Android artifacts
7. optional request receipt publication
8. structured run-history recording
```
Synchronization is additive and reconciliatory: artifact absence never implies
deletion of local content. Equal same-ID definitions are idempotent; divergent
same-ID definitions, unknown references, and association conflicts are reported
explicitly. The affected persisted content is preserved on conflict; the engine
does not silently overwrite it.
There is no exercise, session, body-observation, body-zone-relation, or equipment-definition
tombstone in the current formats. Omitting one of those objects from a later
snapshot is not a deletion request. The only explicit removal signal is
equipment-association V2 `state: cleared`, scoped to one
`(session_id, entry_id)` occurrence.
## 8. Synchronization concurrency
The shared engine serializes synchronization with:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
The TUI waits for an active transaction.
Daemon polling uses non-blocking acquisition and retries later.
A request ID already successfully consumed is not processed as a new request.
## 9. Synchronization history
Every actual run gets a stable:
```text
sy_<uuid-v4>
```
Structured history is stored under:
```text
$XDG_DATA_HOME/trainlog/sync_runs/
```
The TUI exposes list/detail semantics comparable to:
```text
git log
git show
```
## 10. Error philosophy
Trainlog prefers explicit failure over silent corruption.
Hard validation or persistence failure aborts the relevant transaction.
The synchronization layer records useful failure detail rather than masking
known errors with generic placeholders.
## 11. Layering rule
```text
Notcurses / Web / Compose adapters
|
v
typed Core application services
|
+-- domain model and calculations
+-- read models and command services
+-- synchronization and validation
|
v
persistence / MTP transport
```
Rendering does not own persistence rules, SQL, or duplicate business
calculations.
Persistence and MTP code do not depend on Notcurses rendering.
## 12. Body analytics boundary
Body analytics belong to the desktop analysis layer.
```text
Android
real measurements only
|
v
desktop body_observations
|
v
pure derived analytics
|
v
TUI display
```
The analytics layer does not modify canonical observations.
A desktop-only configuration file stores the estimation profile:
```text
$XDG_CONFIG_HOME/trainlog/body_analytics.conf
```
with `~/.config` fallback.
This profile is not synchronized to Android and does not require a SQLite
schema change.
## 13. Audited evolution constraints
Each standalone mutating importer has strict validation and a SQLite
transaction. The V2
association companion is a strict corroboration of the equipment already
carried by the mobile snapshot and does not rewrite divergent state. However,
one definitions/mobile/body-zones/associations publication has no common generation
manifest in the active V3 path and is not one cross-artifact database
transaction. A late association
conflict can therefore follow a successfully committed definitions or mobile
import; replay remains idempotent and existing conflicting content is not
overwritten. The explicit staged generation service now supplies that
separately versioned batch boundary: one source snapshot, immutable artifacts,
manifest-last publication, complete validation, and one destination
transaction including consumption/ACK state. It is intentionally not selected
by the active V3 engine.
Body-zone concurrency uses an internal canonical-state baseline. Equal states
are idempotent; the sole changed side wins; if both local and incoming states
diverge from the baseline, the companion reports a conflict and rolls back.
Secondary lists are never merged by union. A safe exercise-identity merge may
carry the only non-empty zone state, but rejects two different non-empty states
and clears the baseline because identity reconciliation is not acknowledgement.
After a companion has actually been published, the publisher records that
exact snapshot as its own baseline too; a failed file/MTP publication never
acknowledges data that the peer could not have observed.
Android and desktop also have different stored name-normalization behavior:
Android removes diacritics, while the frozen desktop/Python rule preserves them
through NFC plus Unicode case folding. Correcting existing Android keys requires
an explicit migration and collision policy. Finally, Android retains supplied
exercise/equipment relationship tables, but its current picker searches the
complete definition catalog; applying those relations as recommendations is
future gym-catalog behavior.
Custom-equipment reconciliation is deliberately ID-based. Different custom
IDs are not merged by equal or similar display names because their load
semantics and existing occurrence references may differ. The current PC
catalog V1 Android reader also validates its required semantic fields without
the exact-key rejection used by newer V2/companion readers; stricter V1 parsing
needs an explicit compatibility review.
The desktop model/API currently permits bounded supplemental field masks on a
`SETS` profile, while Android creation and V2 inbox validation require those
masks to be zero for `SETS`. The shipped catalog uses supplemental speed and
distance only with `CONTINUOUS`; defining cross-platform behavior for a future
set-based supplemental field is a model-contract decision, not part of this
reconciliation.
## 14. Training knowledge V1 boundary
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Versioned JSON
catalogs under `catalog/` are the only authored scientific source; generated C
data and Android asset loading derive from them. They contain cited anatomy,
movement, exercise and equipment knowledge, not user history. The generated
knowledge audit is evidence output, not an editable source.
The desktop `training_knowledge.h` API exposes immutable catalog records and
stable-ID queries. `EQUIPMENT_KNOWLEDGE_V2` adds a separate immutable relation artifact and bounded
equipment/exercise/zone queries. Zone queries are strictly zone → resolved
exercise → equipment relation; equipment labels never infer anatomy.
`training_context.h` combines one exact persisted exercise
with its stored BODY ZONE relations, optional science, compatible equipment,
latest explicit maximum, and bounded occurrence/set history under one read
snapshot. Android provides the corresponding catalog and repository context.
This composition neither writes SQLite nor seeds catalog mappings. An unknown
runtime ID and a missing scientific record remain valid states.
Scientific BODY ZONE projections and persisted BODY ZONE relations have
different ownership and are never substituted for one another. Labels and
generic equipment descriptions are not runtime identities; an equipment
capability does not create an `ex_<uuid-v4>` exercise or historical association.
The feature is `TRAINING_KNOWLEDGE_V1=PASS`. The temporal contract has
independent PASS evidence:
the readers parse the admitted source forms into exact instants, compare exact
fractions, then use bytewise session and entry ID ties; emitted exclusive
cursors preserve the original timestamp text and IDs. Selection and hydration
share a read snapshot, while separate page calls retain current-data semantics.
Malformed caller cursors and malformed matching stored timestamps fail
explicitly; a selected timestamp beyond C's 40-character output field also
fails explicitly. The initial full-tranche audit's stale temporal-documentation,
Android loader, Meson input, and C role-only query findings were resolved by one
bounded repair chain and independently verified. Its full contract, uncertainty boundary and
future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md).
Canonical mapped exercise names are a separate, small versioned presentation
catalog. Stable `exercise_id` owns both display and normalized metadata for
mapped IDs during PC/Android reconciliation; it does not alter observations,
aliases, BODY ZONES, or equipment context.