## Purpose A scheduled background job in the Journal that generates plausible synthetic routes and activities under a dedicated demo user ("Bruno"), so that fresh deployments and public landing pages have life in the feed without requiring real users. Synthetic content is flagged in the database, capped, and pruned on a configurable retention window. ## Requirements ### Requirement: Persona configuration The Journal SHALL load a demo persona — username, display name, bio, supported locales, and per-locale content pools — from configuration at worker boot, and SHALL fall back to a built-in default persona if no override is supplied or the supplied override fails validation. #### Scenario: No override supplied — built-in default applies - **WHEN** the worker starts with `DEMO_BOT_ENABLED=true` and `DEMO_BOT_PERSONA` is unset - **THEN** the bot uses the built-in default persona (`username=bruno`, playful Berlin-flavoured display name and bio, `locales=["en","de"]`, and the shipped Bruno-voiced name/description pools) - **AND** behaviour is identical to the demo bot before this change #### Scenario: Inline JSON override - **WHEN** `DEMO_BOT_PERSONA` is set to a valid inline JSON object with `username`, `displayName`, `bio`, `locales`, and `content.names` / `content.descriptions` for each listed locale - **THEN** the bot uses that persona — `ensureDemoUser` inserts a row with the persona's username/displayName/bio/sentinel email, and subsequent generated routes and activities draw names and descriptions from the persona's per-locale pools #### Scenario: File-backed override - **WHEN** `DEMO_BOT_PERSONA` is set to `file:` and the referenced file contains a valid persona JSON object - **THEN** the worker reads the file once at boot and uses its contents as the persona #### Scenario: Invalid JSON or schema violation → fall back - **WHEN** `DEMO_BOT_PERSONA` is set but the value is not valid JSON, or fails the persona schema (bad username pattern, empty or too-short pool, unsupported locale, etc.) - **THEN** the worker logs a warn-level entry describing the first validation failure and uses the built-in default persona - **AND** the bot continues to run #### Scenario: File-backed path unreadable → fall back - **WHEN** `DEMO_BOT_PERSONA=file:` but the file cannot be read (missing, permission denied, not a file) - **THEN** the worker logs a warn-level entry and uses the built-in default persona ### Requirement: Persona username clash detection The Journal SHALL refuse to attach the demo bot to a pre-existing non-demo user account when the supplied persona username collides with a human user already registered on the instance. #### Scenario: Configured username belongs to a real user - **WHEN** the worker starts, the persona's username matches an existing `users` row, and that row has no marker identifying it as a prior demo user (i.e. it was registered via the normal signup flow) - **THEN** the worker logs an error-level "demo persona username clash" entry naming the colliding username - **AND** the generation + prune jobs are not scheduled for this process — the bot stays disabled until the operator picks a different username - **AND** the rest of the Journal continues to serve requests normally ### Requirement: Demo user bootstrap The Journal SHALL ensure a dedicated bot user exists when the demo bot starts, creating it on first run if missing. The user's identity (username, display name, bio, sentinel email local-part) is derived from the active persona — either the operator-supplied persona or the built-in default. #### Scenario: Bot user created on first run - **WHEN** the Journal worker starts with `DEMO_BOT_ENABLED=true` and no `users` row matches the persona's username - **THEN** a new `users` row is inserted with that username, the persona's display name, the persona's bio, a sentinel email `@`, no passkey credentials, and `terms_accepted_at` + `terms_version` populated at the current version - **AND** subsequent worker startups are idempotent — no second row is inserted #### Scenario: Demo user has no usable credentials - **WHEN** any request attempts to authenticate as the demo user via passkey or magic-link - **THEN** authentication fails because no passkey is registered and no mailbox receives magic-link mails ### Requirement: Synthetic content generation job The Journal SHALL run a recurring background job that generates one public route and one linked public activity for the demo user per run, subject to an env flag and a rate cap. The generated name and description are drawn from the active persona's per-locale content pools. #### Scenario: Disabled in non-production environments - **WHEN** the `DEMO_BOT_ENABLED` env var is absent or any value other than `"true"` - **THEN** the job body is a no-op: no BRouter calls, no inserts, no errors #### Scenario: Enabled generation flow (decide-to-walk fires) - **WHEN** `DEMO_BOT_ENABLED=true`, the local hour is within 07:00–21:00, the per-tick Bernoulli roll fires, and the hard cap has not been reached - **THEN** the job picks a random start and end point within the configured seed region, calls BRouter with the `trekking` profile, persists the returned GPX as a new route with `visibility='public'` and `synthetic=true`, and inserts a linked activity with the same GPX, `visibility='public'`, `synthetic=true`, a plausible `started_at` and `duration`, and a persona-voiced name + description sampled from one of the persona's supported locales - **AND** the route and activity are attributed to the demo user #### Scenario: Locale restricted to a single language - **WHEN** the persona's `locales` list is `["en"]` - **THEN** every generated route's name and description come from the persona's English pool — the German pool is never sampled #### Scenario: Decide-to-walk does not fire - **WHEN** the local hour is outside 07:00–21:00, or the Bernoulli roll does not fire - **THEN** the job returns without inserting anything #### Scenario: BRouter failure is tolerated - **WHEN** the BRouter call returns no route, a rate-limit, or an error - **THEN** the job logs the failure, inserts nothing, and exits without throwing — the next scheduled tick retries #### Scenario: Hard cap prevents runaway growth - **WHEN** there are already 40 or more synthetic items created in the last 14 days - **THEN** the job skips generation for that tick #### Scenario: Singleton scheduling prevents overlap - **WHEN** a tick fires while the previous run is still executing - **THEN** the new tick is skipped (pg-boss singleton semantics) so the job cannot overlap itself ### Requirement: Synthetic content retention The Journal SHALL run a recurring job that deletes synthetic routes and activities older than a configurable window. #### Scenario: Prune removes old synthetic content - **WHEN** the prune job runs with `DEMO_BOT_RETENTION_DAYS=14` - **THEN** every row in `journal.routes` and `journal.activities` with `synthetic=true` and `created_at < now() - 14 days` is deleted - **AND** route-version rows cascade-delete via existing foreign keys - **AND** rows with `synthetic=false` are never touched #### Scenario: Prune is a no-op when nothing is old - **WHEN** the prune job runs and no synthetic rows exceed the retention window - **THEN** no DELETE statements execute and the job returns normally #### Scenario: Disabled in non-production environments - **WHEN** `DEMO_BOT_ENABLED` is not `"true"` - **THEN** the prune job body is a no-op ### Requirement: Initial backfill On first enablement (when no synthetic content exists yet) the Journal SHALL populate the demo profile with a small batch of items so the first visitor does not see a single-item feed. #### Scenario: First enablement backfills several items - **WHEN** the bot is enabled for the first time and the count of synthetic routes is 0 - **THEN** the generation job produces 3–5 items in sequence during its first run - **AND** subsequent runs produce one item at a time as normal ### Requirement: Seed region is configurable The Journal SHALL read the seed region (bounding box) from an env var so the deployment can change it without a code change. #### Scenario: Env-configured region - **WHEN** `DEMO_BOT_REGION` is set to a JSON object containing a `bbox` array of four numbers `[west, south, east, north]` - **THEN** all generated start and end points fall within that box #### Scenario: Sensible default - **WHEN** `DEMO_BOT_REGION` is unset - **THEN** the job uses a documented default region (inner Berlin) so that out-of-the-box runs still produce plausible Bruno-style walks