Define Web Sessions preparation persistence contract

This commit is contained in:
fy59 2026-09-17 20:13:21 +02:00
parent a5cdbdf2b9
commit ec17fca45d
5 changed files with 130 additions and 5 deletions

View file

@ -0,0 +1,83 @@
# Trainlog Web Sessions V1 contract
Status: implementation contract for `TRAINLOG_WEB_SESSIONS_V1`.
## Scope and domain boundaries
The Sessions route has three independent views:
- Preparation lists manual preparations and immutable imported AI proposals.
- Resume lists lifecycle-backed execution drafts that are not finalized or causally deleted.
- History lists completed sessions. A missing `ended_at` is rendered as an unknown end time; it
does not make a historical row an active draft.
An AI proposal is never mutated by viewing it or by deriving a manual preparation. Derivation is
an explicit command which creates a new `sp_<uuid-v4>` preparation and records the proposal ID and
payload fingerprint used. A manual preparation is not an execution draft or completed session.
## Manual preparation persistence
Desktop schema v23 adds:
- `session_preparations`, the mutable head and delivery state;
- `session_preparation_revisions`, immutable revision metadata;
- `session_preparation_entries`, immutable ordered occurrence snapshots;
- `session_preparation_requests`, durable HTTP idempotency results;
- `session_preparation_deliveries`, the preparation-revision to execution identity relation.
Preparation IDs use `sp_<uuid-v4>`, occurrence IDs use `spe_<uuid-v4>`, revision IDs use
`spr_<uuid-v4>`, and execution IDs use the existing `se_<uuid-v4>` namespace. Revision content is
immutable. Updating a preparation inserts a complete successor revision and atomically advances
the head. Occurrence IDs survive reordering and compatible edits. Two occurrences of one exercise
remain distinct.
`editing_state` (`draft` or `ready`) and `delivery_state` (`local`, `pending`, `acknowledged`,
`remote_unknown`, or `cancelled`) are independent. Marking a complete revision ready changes no
Android state. Preparing it for Android creates one durable delivery and deterministic execution
identity per explicit command. Retrying the command with its idempotency key returns that same
identity. Later preparation edits never alter a published revision or execution.
## Validation
Titles and notes are UTF-8 and bounded by the existing name/note limits. A preparation may be
saved with no occurrences while it is a draft. Ready revisions require a non-empty title and at
least one occurrence. Every occurrence resolves a non-retired exercise and a compatible equipment
choice at commit time. `SETS/REPS`, `SETS/DURATION`, and `CONTINUOUS/DURATION` are the only legal
profiles. Continuous work has a duration target and never a target set. Assistance, external load,
and no load remain distinct. JSON `null`, zero, and absence retain distinct meanings; non-finite
numbers are rejected by the JSON parser and Core validation.
Every update supplies the expected revision and is checked in the write transaction. A mismatch
returns a conflict without partial mutation. Request idempotency is persistent, so a lost HTTP
response can be replayed without creating another preparation.
## Read API
Lists use bounded offset pages with deterministic stable-ID tie breaking. The response returns a
snapshot revision. If a write changes that revision between page requests, the client discards its
accumulated pages and explicitly reloads. Search matches the title or an exercise display name.
Dates mean planned calendar dates for preparations and occurrence dates for history. Zones are
offered only where the persisted exercise relations provide them.
Stable details are addressed by kind and identity under `/api/v1/sessions/...`. Unknown API routes
remain JSON 404 responses; recognized non-API paths use the embedded SPA fallback.
## Synchronization and Android
Ready deliveries use a separate `trainlog-session-preparations` V1 generation participant. It does
not change `TRAINLOG_FORMAT_V1`, mobile V3/V4, or execution-drafts V1. The artifact contains complete
immutable revision snapshots and explicit cancellation state. Android consumes a delivery into a
pending prepared-session store. Starting it is an explicit Android action: an occupied active-draft
singleton is never overwritten. Consumption and start are idempotent by delivery, preparation,
revision, execution, and occurrence IDs.
Delivery is shown as acknowledged only when the correlated generation ACK proves that the exact
artifact containing that revision was consumed. Publication time or a global successful sync is
not sufficient evidence. Old peers which do not advertise this participant are rejected before a
generation containing it is published.
## Explicit exclusions
This V1 does not implement Programs, Analysis, or the standalone Exercises route. It does not add
live Web workout capture, historical correction, automatic training prescriptions, proposal
acceptance, or automatic proposal-to-execution conversion.

View file

@ -16,7 +16,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 22
#define TRAINLOG_DATABASE_SCHEMA_VERSION 23
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -884,6 +884,45 @@ static const char *const MIGRATE_V21_TO_V22_SQL =
"audit_json TEXT NOT NULL);"
"PRAGMA user_version=22;COMMIT;";
/* WHY: a Web-authored preparation is neither an imported AI proposal nor an
* execution draft. CONTRACT: v23 stores immutable preparation revisions,
* stable ordered occurrences, durable request replay, and delivery identity
* without changing completed history or frozen exchange formats. INVARIANT:
* migration invents no preparation, execution, delivery, or acknowledgement. */
static const char *const MIGRATE_V22_TO_V23_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE session_preparations("
"preparation_id TEXT PRIMARY KEY,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,"
"updated_at TEXT NOT NULL,editing_state TEXT NOT NULL CHECK(editing_state IN('draft','ready')),"
"delivery_state TEXT NOT NULL DEFAULT 'local' CHECK(delivery_state IN('local','pending','acknowledged','remote_unknown','cancelled')),"
"source_proposal_id TEXT,source_payload_sha256 TEXT);"
"CREATE TABLE session_preparation_revisions("
"revision_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id) ON DELETE RESTRICT,"
"parent_revision_id TEXT,title TEXT NOT NULL,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),"
"planned_for TEXT,notes TEXT,created_at TEXT NOT NULL,"
"CHECK(length(CAST(title AS BLOB))<=200),CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000),"
"UNIQUE(preparation_id,revision_id));"
"CREATE TABLE session_preparation_entries("
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id) ON DELETE RESTRICT,"
"entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),exercise_id TEXT NOT NULL,"
"equipment_id TEXT,recording_mode TEXT NOT NULL CHECK(recording_mode IN('sets','continuous')),"
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL,"
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT,"
"PRIMARY KEY(revision_id,entry_id),UNIQUE(revision_id,position),"
"CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000));"
"CREATE INDEX session_preparation_entries_exercise ON session_preparation_entries(exercise_id);"
"CREATE TABLE session_preparation_requests("
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,preparation_id TEXT NOT NULL,revision_id TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
"CREATE TABLE session_preparation_deliveries("
"delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id),"
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),execution_session_id TEXT NOT NULL UNIQUE,"
"state TEXT NOT NULL CHECK(state IN('pending','acknowledged','remote_unknown','cancelled')),"
"created_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
"UNIQUE(preparation_id,revision_id,delivery_id));"
"PRAGMA user_version=23;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
@ -1755,6 +1794,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 22) {
status = execute_sql(database, MIGRATE_V21_TO_V22_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 23) {
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -1770,7 +1812,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v22" : "migrate database to schema v22",
version == 0 ? "create schema v23" : "migrate database to schema v23",
database->connection,
SQLITE_ERROR
);

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=",
strlen("migrate database to schema v22: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=",
strlen("migrate database to schema v23: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v23") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);