trails/openspec/changes/configurable-demo-persona/specs/demo-activity-bot/spec.md
Ullrich Schäfer fc4485f6ef
Apply configurable-demo-persona: per-instance demo identity + voice
Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.

- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
  persona username is already a real user; server declines to
  schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
  boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:30:32 +02:00

81 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## ADDED 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
## MODIFIED Requirements
### 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