trails/openspec/specs/demo-activity-bot/spec.md
Ullrich Schäfer 91e80ace36
Archive demo-activity-bot, pg-boss-background-jobs, configurable-demo-persona
Fold completed deltas into main specs (activity-feed, route-management,
infrastructure, planner-session), add new background-jobs and
demo-activity-bot capability specs, and move the three change dirs to
openspec/changes/archive/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:17:39 +02:00

8.5 KiB
Raw Permalink Blame History

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:<absolute-path> 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:<path> 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 <username>@<domain>, 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:0021: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:0021: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 35 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