Luminator Technology Group
CreatorStudiodocs/product-backlog · product documentation

CreatorStudio Suite — Product Backlog (Features, Descriptions & Acceptance Criteria)

Document type: Product-Owner backlog — ADO ticket & test-case source · Status: v1.0 Date: 2026-08-12 · Owner: Product · Source of truth: PRD.md @ main


How to use this document

Every feature of the CreatorStudio Suite appears below as a self-contained backlog entry with a Description (user-story framing plus context) and numbered, testable Acceptance criteria. The entries are written to be pasted directly into Azure DevOps and to seed test cases:

This document Azure DevOps mapping
Epic heading (E1–E15) Epic work item
Feature entry (F-…) Feature or User Story work item (title + Description field + Acceptance Criteria field)
Each numbered acceptance criterion ≥ 1 Test Case — Given = preconditions, When = test steps, Then = expected result
Verification hook line Pointer to the automated test / parity guard that already covers the criterion (link as ADO "Tested By" where useful)

Feature IDs embed the PRD requirement ID (e.g. F-PRT-01PRD-PRT-01) so ADO tickets stay traceable to the requirement catalogue. Status: all features below are implemented and verified unless the entry carries a ⚠ Known gap note — this backlog doubles as a regression-test charter, not just a to-build list.


E1 — Portal, Suite UX & Documentation (PRD-PRT)

F-PRT-01 · One portal origin for every surface

Description. As any suite user (author, operator, auditor, technician), I reach every web surface from a single origin on port :8080, so that one endpoint is all that must be exposed, secured and remembered, and links between apps never break. The root serves the Monitor cockpit; /portal is the landing menu; /creator/, /monitor/, /player and /docs serve the four apps.

Acceptance criteria

  1. Given the portal is running, when I browse /, then the Monitor cockpit loads (the default screen).
  2. When I browse /portal, /creator/, /monitor/, /player, /docs, then the landing menu, authoring app, dashboard, browser player and Documentation Center load respectively — all from the same :8080 origin.
  3. Given a legacy route (pre-consolidation URL), when I request it, then I receive a redirect to its current home, never a 404.
  4. No app surface requires any port other than :8080 to be reachable from the browser.

F-PRT-02 · One design system and a persistent suite rail

Description. As a suite user switching between authoring, monitoring and playback, I experience one visual language and one navigation habit, so the suite feels like a single product. Both Angular apps consume the Figma/PrimeNG theme (@primeuix/themes preset, dark scheme by default) and show a persistent left rail (Creator / Engine / Player, with Manual and Documentation at the foot). The kit is extracted as the reusable packages/suite-ux package (tokens, base CSS, rail/theme components, shell templates) so the next product starts from the same system.

Acceptance criteria

  1. Creator and Monitor render from the same theme preset; the default colour scheme is dark.
  2. The left rail is present on both apps and lists Creator, Engine and Player, with Manual and Documentation at the foot; switching apps is one click and preserves the origin.
  3. packages/suite-ux builds independently and exports the tokens, base CSS, rail/theme components and shell templates.
  4. A drift guard (dedicated tsconfig in web/) fails the build if app code diverges from the extracted kit's contracts.

F-PRT-03 · Responsive, touch-ready Monitor and Creator

Description. As an operator or author on a tablet or phone (e.g. a technician at a vehicle), I can use Monitor and Creator on small screens in both orientations, so field work does not require a laptop.

Acceptance criteria

  1. At viewport widths below 960 px and again below 640 px, layout reflows per breakpoint; no horizontal page scrolling appears.
  2. On small screens the top navigation collapses into a drawer that opens/closes by tap.
  3. Both portrait and landscape orientations render usable layouts.
  4. On coarse-pointer (touch) devices, hit areas meet touch size and legends/popovers open on tap.

F-PRT-04 · Documentation Center assembled live from the repo

Description. As any stakeholder, I read the consolidated handbook at /docs, assembled from the repo's Markdown on every request, so documentation can never lag the code it describes. Decks and leave-behinds are served alongside; npm run docs regenerates the distributable copies and CI fails on drift.

Acceptance criteria

  1. /documentation.html (and raw /documentation.md) reflect the current Markdown sources at request time — an edit to a source file is visible on the next request without a rebuild.
  2. The Documentation Center serves the decks and leave-behind documents.
  3. npm run docs regenerates the checked-in distributable copies.
  4. CI fails when the built copy drifts from the sources.

F-PRT-05 · Left-nav sub-menus for sectioned screens

Description. As a user of a sectioned screen (e.g. the LED section), I navigate its parts from a sub-menu in the left nav — URL-driven via ?tab= and shown only while the section is open — so navigation lives in one consistent place. In-page tab strips are not used.

Acceptance criteria

  1. Opening a sectioned screen reveals its sub-menu under the section's nav entry; leaving the section hides it.
  2. Selecting a sub-menu item updates the ?tab= URL parameter; loading a URL with ?tab= opens the corresponding sub-section directly (deep link).
  3. No in-page tab strip or chip row is used for section-level navigation.

F-PRT-06 · /build-stats — the measured build record

Description. As a stakeholder evaluating the AI-assisted build, I open /build-stats and see the measured record (commit counts, sizes, cadence) with figures baked in as constants and refreshed by scripts/build-stats-measure.mjs.

Acceptance criteria

  1. /build-stats serves the page with the measured figures.
  2. Running scripts/build-stats-measure.mjs refreshes the baked-in constants from the repository.

F-PRT-07 · Interactive, use-case-based user manual

Description. As a new author or operator, I open /manual (alias /help) and learn by doing: "I want to…" guides walk me through the real screens step by step over the live screenshot set (docs/media/), with live search, topic filters, per-step progress ticked off in the browser, a screenshot lightbox, and deep links straight into the running apps. The manual is embedded in the suite — a portal card, a Documentation Center card and the suite rail's Manual item all lead there — so help is never something I have to go looking for.

Acceptance criteria

  1. /manual, /manual/, /manual.html and /help all serve the manual.
  2. Guides are framed as "I want to…" use cases and step through the real screens using the live screenshots from docs/media/.
  3. Search filters guides live as I type; topic filters narrow the set.
  4. Ticking a step marks progress in the browser and the progress persists across reloads within the session.
  5. Clicking a screenshot opens it in a lightbox; step deep links open the corresponding screen in the running app.
  6. The portal card, the Documentation Center card and the suite rail's Manual item each navigate to the manual.

E2 — Projects, Tenancy & Configuration Lifecycle (PRD-PRJ)

F-PRJ-01 · Projects as self-contained configurations

Description. As an Announcement Author, I organise my work into named Projects — each a self-contained config (playlists, triggers, voices, lexicon, geofences, volume rules, LED templates) — so I can keep drafts, variants and per-contract configurations side by side.

Acceptance criteria

  1. I can create, rename, duplicate, delete, import and export a project.
  2. A duplicated project is a deep copy — edits to it never affect the original.
  3. Deleting the last remaining project is refused with a clear message.
  4. Every authorable entity (playlists, triggers, voices, lexicon, geofences, volume rules, LED templates) is stored per project.

F-PRJ-02 · Single published config per tenant/fleet, versioned atomically

Description. As an operator, I rely on exactly one project being published per (tenant, fleet) at any time, so the fleet's behaviour is unambiguous. Publishing upserts engine_config and appends a monotonically numbered config_versions row in one transaction, recording which project it came from.

Acceptance criteria

  1. Given project A is published, when I publish project B for the same (tenant, fleet), then B replaces A as the single live config.
  2. Each publish appends a config_versions row whose version number is strictly greater than the last.
  3. The engine_config upsert and the version append happen in one transaction — no observable state where one exists without the other.
  4. The version row records the source project.

F-PRJ-03 · Publish gate + live Issues screen (config linting)

Description. As an author, I am stopped from publishing a broken config: publication is blocked while any enabled trigger references a missing playlist, and a dedicated Issues screen lints the config live with a nav count badge, so problems are found while authoring, not on the vehicle.

Acceptance criteria

  1. Given an enabled trigger references a missing playlist, when I attempt to publish, then the publish is blocked and the reason is shown.
  2. The Issues screen lists errors: unspoken playlists, unresolvable variables, dangling playlist references, never-fires triggers.
  3. The Issues screen lists warnings: dead single playlist behind an interior/exterior split, event variables the trigger cannot provide, gate-aware variable checks on custom triggers.
  4. The nav badge shows the current issue count and updates live as I edit.
  5. Every issue deep-links to the offending entity (opens the right screen and item).

F-PRJ-04 · ~1 s hot-swap of the published config

Description. As an author, my published change is live on the fleet in about a second without restarting anything — the engine hot-swaps via Postgres LISTEN/NOTIFY, or via the retained MQTT config-notify + portal API for vehicles that cannot reach the database.

Acceptance criteria

  1. Given the engine is running, when a new config version is published, then the engine applies it without a process restart.
  2. Publish-to-live time is ~1 s on the LISTEN/NOTIFY path.
  3. A vehicle-hosted engine with no database access picks up the change via the retained MQTT config notify + portal API.

F-PRJ-05 · Remote-first config with file fallback

Description. As an operator of a degraded or air-gapped installation, the engine reads config remote-first but falls back to the on-disk file at CONFIG_PATH (hot-reloaded), and CONFIG_SOURCE_ENABLED=false forces file-only mode — so the engine always has a config to run.

Acceptance criteria

  1. With the remote source reachable, the engine uses the remote config.
  2. With the remote source unreachable, the engine serves from the on-disk file at CONFIG_PATH.
  3. Editing the on-disk file while in file mode hot-reloads it without restart.
  4. CONFIG_SOURCE_ENABLED=false forces the file even when the remote is reachable.

F-PRJ-06 · One-click rollback with append-only history

Description. As an author who shipped a bad change, I roll back to any earlier version in one click. Restore re-publishes as a new version — history is append-only, so the audit trail of what the fleet ran, and when, is never rewritten.

Acceptance criteria

  1. The version history lists all versions; any earlier version offers a one-click restore.
  2. Restoring version N creates version M (M > latest), whose content equals N; no existing row is mutated or deleted.
  3. After restore, the engine hot-swaps to the restored content (per F-PRJ-04).

F-PRJ-07 · Tenant-scoped authoring, zero data sharing

Description. As a platform operator serving multiple transit operators, each tenant's projects, config, history and audit data are fully isolated. The active tenant resolves from ?tenant= → host-shell global → last used → default.

Acceptance criteria

  1. Projects, published config, version history and audit data created under tenant A are not visible under tenant B by any UI or API path.
  2. The tenant resolves in the documented precedence order: URL ?tenant=, then host-shell global, then last used, then default.
  3. Switching tenant switches the whole authoring context (projects list, live config, history).

F-PRJ-08 · API-first persistence with graceful offline fallback

Description. As an author, my edits save automatically to the portal Creator API (GET/PUT/DELETE /api/projects, same-origin, debounced, flushed on page hide), and keep working offline via a localStorage fallback — with honest status chips when storage degrades, so I always know whether my work is safe.

Acceptance criteria

  1. Edits persist via the same-origin /api/projects API; saves are debounced and flushed when the page is hidden/closed.
  2. With the API unreachable, editing continues against localStorage and the chip reads "Offline — local only".
  3. Degraded local storage shows "Local backup paused"; a total inability to save shows "Not saving".
  4. A legacy single-config store migrates automatically into a "Default" project on first load.

F-PRJ-09 · Versioned JSON envelope, undo, referential integrity

Description. As an author and integrator, config round-trips as a versioned JSON envelope (luminator.announcement-config, schemaVersion 1.5.0) interoperable with the engine and the legacy app. Every edit is undoable via full-snapshot history with keystroke coalescing; deleting a referenced entity scrubs its references; import replaces the open project (undoable), while "import as a new project" is the non-destructive path.

Acceptance criteria

  1. Export → import round-trips a project without loss; the envelope carries type and schemaVersion.
  2. The engine and the legacy app both accept the exported envelope.
  3. Undo restores the previous state for any edit; a burst of keystrokes coalesces into one undo step.
  4. Deleting an entity that others reference (e.g. a playlist used by a trigger) removes those references — no dangling IDs remain.
  5. Import-into-open-project warns that it replaces content and is undoable; "import as new project" leaves existing projects untouched.

F-PRJ-10 · Live-config import & round-trip editing

Description. As an operator who received a config as a file, I import it in the dashboard's Config panel and it publishes to the live config (visible immediately); as an author, I pull the live published config back into the Creator as an editable project via /creator/?importPublished=1 — so a file received from anywhere can be viewed, edited and re-published.

Acceptance criteria

  1. Importing a config file in the Monitor Config panel publishes it; the live fleet behaviour and the panel reflect it immediately.
  2. Opening /creator/?importPublished=1 creates an editable Creator project from the currently published config.
  3. The imported project can be edited and re-published, completing the round trip.

F-PRJ-11 · Azure-hosted, fully self-hostable data layer

Description. As a platform owner, the data layer runs on Azure (Azure PostgreSQL for projects, engine_config, config_versions, proof_of_play; Azure Blob for the shared TTS cache tier) with the portal API in front — and the entire backend also runs 100 % locally against a stock Postgres container, so development, demos and on-premise deployments need no cloud at all.

Acceptance criteria

  1. The schema lives in db/migrations/ and npm run db:migrate applies it idempotently.
  2. Browsers and vehicles reach data only through the portal API — no direct database exposure.
  3. From an empty machine, db:start + stack:local brings up the full stack against a local Postgres container with no cloud dependency.
  4. Azure Blob serves as the shared TTS cache tier when configured.

E3 — Content: Playlists & Elements (PRD-CNT)

F-CNT-01 · Playlist model: five element types + speaker routing

Description. As an author, I compose a playlist as an ordered list of elements of exactly five types — static-text, dynamic-text, pause, audio-file, audio-ref — each routed to interior, exterior or both speakers, so one playlist expresses everything an announcement needs.

Acceptance criteria

  1. All five element types can be added, reordered and removed; no other element type is offered.
  2. Speaker routing is settable per element to interior / exterior / both and is honoured at dispatch.
  3. Element order in the playlist is the spoken order.

F-CNT-02 · Voice per playlist with per-element override

Description. As an author, I set a voice per playlist and override it per element (e.g. a Swedish sentence inside an English announcement); an element with its own voice splits the TTS run and resolves its variables in that voice's language.

Acceptance criteria

  1. A playlist-level voice applies to all elements without an override.
  2. An element-level voice overrides the playlist voice for that element only.
  3. A voice change mid-playlist splits the synthesis run at that boundary.
  4. Variables inside an overridden element resolve in the override voice's language.

F-CNT-03 · Dynamic text over the live variable catalogue (16 locales)

Description. As an author, I write dynamic text with variables resolved from live journey state — {nextStop}, {destination}, {skippedStop}, {exitSide}, {situationHeading}, {connectingServices}, {stateOfCharge} and the rest of the catalogue — language-aware via name_Multilanguage across 16 locales, so one authored line speaks correctly for every stop, route and language.

Acceptance criteria

  1. Every variable offered by the Creator's picker resolves against live journey state in the engine.
  2. Stop/destination names resolve in the announcement voice's language when name_Multilanguage provides it (16 locales).
  3. Verification hook: the variable-parity test fails the build if the Creator offers a variable the resolver cannot fill.

F-CNT-04 · Per-element volume via SSML prosody; no authorable pitch

Description. As an author, I set per-element volume (0–100), applied as SSML prosody where the supplier's capability descriptor declares support; plain-text suppliers drop it predictably with a log line and an authoring-time warning. Voice pitch is deliberately not authorable (consistent fleet voice image).

Acceptance criteria

  1. On a supplier with prosodyVolume, element volume audibly applies to that element only.
  2. On a supplier without SSML support, volume is dropped; the engine logs it and the Creator warns at authoring time.
  3. No authoring surface offers a pitch control.

F-CNT-05 · One-action multilingual duplication

Description. As an author of a bilingual service, one action duplicates a playlist's spoken block into a second language and voice, preserving pauses and cadence, so multilingual variants take seconds, not re-authoring.

Acceptance criteria

  1. The duplicate-in-language action appends the spoken block re-voiced in the chosen language.
  2. Pauses and element order (cadence) are preserved in the duplicated block.
  3. The result remains fully editable per element.

F-CNT-06 · Pre-recorded stop-name clips substitute for TTS

Description. As an operator with studio-recorded stop names, recorded clips substitute for TTS wherever a stop-name variable matches a recording (bulk import parses name + language from file names); the rest of the announcement is still synthesised — so brand-quality names blend with dynamic speech.

Acceptance criteria

  1. Bulk import derives stop name and language from clip file names.
  2. When a stop-name variable's resolved value matches a recording in the right language, the recording plays in place of synthesis.
  3. Elements around the substitution still synthesise normally; the announcement remains one continuous clip stream.

F-CNT-07 · Real-TTS audition before publish

Description. As an author, I audition any playlist through the engine's real TTS supplier — with realistic sample variables filled and the lexicon applied — so what I hear at my desk is what passengers will hear.

Acceptance criteria

  1. Audition synthesises via the configured live supplier (not a browser voice).
  2. Sample variables fill with realistic values; lexicon entries apply.
  3. Audition works without publishing and leaves the published config untouched.

F-CNT-08 · Printable configuration documentation sheet

Description. As an author or reviewer, I print a documentation sheet of the audio configuration — every playlist with rendered script, voice and firing triggers; every enabled trigger with conditions, gates and priority; custom triggers as readable rules; the speech setup — flowing across pages for sign-off or a binder.

Acceptance criteria

  1. The sheet lists every playlist with its rendered script, voice and the triggers that fire it.
  2. Every enabled trigger shows conditions, gates and priority; custom triggers appear as readable rules.
  3. The speech setup (supplier, voices) is included.
  4. Print-to-PDF flows across pages without clipped content.

F-CNT-09 · AI authoring assistant with reviewable, validated plans

Description. As a non-technical author, I describe what I want in plain language at /assistant, and get back a reviewable plan of playlists, built-in triggers and custom rules — every item individually tickable with rationale, replace-warnings where a trigger already plays something, validated against the real domain model before apply (model output is never trusted as config), applied as one undo entry. Served local-first via the portal's /ai/assistant against any OpenAI-compatible endpoint (default: local Ollama); an offline keyword-drafting fallback is clearly badged and travels the same validate → review → apply path.

Acceptance criteria

  1. A plain-language request yields a plan of concrete items, each tickable on/off with a rationale.
  2. Items that would replace an existing trigger's playlist carry an explicit replace-warning.
  3. The plan is validated against the domain model before apply; invalid model output cannot reach the config.
  4. Applying the plan is exactly one undo entry.
  5. With no AI endpoint reachable, the keyword-drafting fallback engages, visibly badged, using the same validate → review → apply path.

F-CNT-10 · Project timezone

Description. As an author of time-based behaviour, the project carries an IANA timezone (with a live "now in this zone" check) used by time/weekday/calendar facts and published to the engine, so schedules mean local time, not server time.

Acceptance criteria

  1. The timezone setting accepts IANA zone names and shows the current time in the chosen zone.
  2. Time, weekday and calendar facts evaluate in the project timezone in Creator previews and in the engine.
  3. The timezone is part of the published config.

F-CNT-11 · Rule-based volume adaptation (combinable conditions)

Description. As an operator, announcement loudness follows authored rules, each freely combining time of day, weekdays, route, stop and geofence (circle or polygon from the geofence library) — all conditions must hold and the most specific matching rule wins — e.g. "quiet at night in residential zones, but only on weekdays on route 5".

Acceptance criteria

  1. A rule may combine any subset of: time window, weekdays, route, stop, geofence; it matches only when all its conditions hold.
  2. When several rules match, the most specific one (most conditions) wins deterministically.
  3. Route and stop values are picked from the imported GTFS feed; free text is accepted when no feed is loaded.
  4. Time evaluates in the project timezone; geofence conditions require a trusted GNSS fix.

E4 — Triggers & Rules (PRD-TRG)

F-TRG-01 · The canonical 33 built-in trigger types

Description. As an author, I choose from the canonical 33 built-in trigger types covering journey lifecycle (7), stop flow (7), sensors (2), distance/time thresholds (5), spatial/motion (3), service/passenger (7) and local-only (2) — so every standard transit announcement moment is authorable without custom logic.

Acceptance criteria

  1. The Creator offers exactly the 33 built-in trigger types, grouped as catalogued.
  2. Each type fires in the engine on its defined journey condition.
  3. Verification hook: the bidirectional parity guard (contract test + CI step) fails the build if the engine's trigger set and the authoring manifest drift in either direction.

F-TRG-02 · Transition-edge firing with debounce and settle window

Description. As a passenger, I never hear duplicate or stale announcements: triggers fire on state transitions only, debounced 1500 ms per vehicle+kind, with a settle window suppressing the burst of stale retained state after (re)connect; GPS-derived triggers evaluate only on a trusted GNSS fix.

Acceptance criteria

  1. A condition that stays true fires once (on the transition), not repeatedly.
  2. Two firings of the same kind on the same vehicle within 1500 ms collapse into one.
  3. After the engine (re)connects to the broker, the retained-state burst inside the settle window fires nothing.
  4. GPS-derived triggers do not evaluate on an untrusted/absent fix.

F-TRG-03 · Typed threshold conditions

Description. As an author, threshold triggers carry typed conditions with explicit units — time in seconds; distance in metres or feet (threshold only; spoken values are metres); geofence radius + centre; speed below/above/between in km/h or mph; occupancy %; exit side — so a rule means the same thing to the author and the engine.

Acceptance criteria

  1. Each threshold trigger exposes only its meaningful condition fields, with units labelled.
  2. Feet/mph inputs convert correctly; speech output remains metres.
  3. Speed "between" validates lower < upper; invalid ranges are rejected at authoring time.

F-TRG-04 · Universal prerequisite gates (fail-closed, held & retried)

Description. As an author, every trigger supports the universal prerequisite gates — door open / door closed / stop button pressed / velocity below / velocity above — authorable in the Creator and fail-closed (no gate signal ⇒ no fire), with gate-blocked fires held and retried for a bounded window so a briefly-blocked announcement still plays when the gate opens.

Acceptance criteria

  1. Any gate can be attached to any built-in trigger in the Creator.
  2. A trigger whose gate condition is unmet does not fire; missing gate data also blocks (fail-closed).
  3. A gate-blocked fire is retried and goes out if the gate is satisfied within the hold window; after the window it is dropped.

F-TRG-05 · Custom fact-based triggers (40 live facts, try-it verdict)

Description. As an advanced author, I build custom triggers as nested ALL/ANY/NOT expressions over the catalogue of 40 live facts in 7 categories (time & calendar, journey, stops, doors, movement, energy — stateOfCharge/charging, passengers & service) plus named geofences; fire mode is becomes-true or while-true with a cooldown, and a live "Try it" verdict evaluates my rule against current state while I author it.

Acceptance criteria

  1. Conditions nest arbitrarily with ALL / ANY / NOT over the fact catalogue and named geofences.
  2. becomes-true fires on the rising edge; while-true re-fires no more often than the cooldown.
  3. "Try it" shows the rule's current verdict live during authoring.
  4. Verification hook: a parity test locks the authoring fact catalogue to the engine's snapshotFacts().

F-TRG-06 · Named geofence library with map editing

Description. As an author, I maintain a named geofence library — circles 20–4000 m and polygons of ≥3 vertices — edited directly on a map (drag/insert/delete corners, move, resize, duplicate, recolour) and referenced by name from custom triggers and volume rules. The imported GTFS feed's stops overlay the map as a placement guide.

Acceptance criteria

  1. Circles enforce the 20–4000 m radius range; polygons enforce ≥3 vertices.
  2. Map editing supports corner drag/insert/delete, move, resize, duplicate and recolour.
  3. Custom triggers (and volume rules) reference geofences by name; renames follow through.
  4. With a GTFS feed imported, every stop with coordinates is shown on the map (name, code and position on hover); the overlay is toggleable and defaults to on, and clicking a stop while drawing snaps the circle centre or the next polygon corner exactly to the stop.
  5. Without a feed (or with the dashboard offline) the editor works unchanged and shows no overlay.
  6. The overlay is filterable by route and by destination (headsign) — both selectable from the feed, destination options narrowing to the chosen route — and the filtered line's shape polylines draw on the map in the route's GTFS colour.
  7. Stop names can be shown as labels beside the pins (own toggle, on by default, falling back to hover-only above a density cap).

F-TRG-07 · Split playlists, sequences and repetitions per trigger

Description. As an author, a trigger row resolves to real-world output shapes: separate interior/exterior playlists, ordered sequences, and repetitions — with the Creator preview pinned to the engine's actual resolveJobs logic, so what the row shows is what the vehicle does.

Acceptance criteria

  1. A trigger can play different playlists interior vs exterior, a sequence of playlists, and N repetitions.
  2. The trigger row's preview matches engine resolution exactly.
  3. Verification hook: the triggerJobsParity test pins Creator preview to resolveJobs.

F-TRG-08 · One unified triggers list

Description. As an author, built-in and custom triggers live in one list — one place to see everything that can make the vehicle speak or show, with no second screen to forget.

Acceptance criteria

  1. The triggers screen lists built-in and custom triggers together.
  2. Enable/disable, priority and playlist binding are edited in the same list for both kinds.
  3. The list is filed into category drawers (journey, stops, doors, destination, disruptions, passenger info, vehicle & location, devices, custom); each folds away and states how many triggers it holds and how many of them are active.
  4. A search box narrows the list by name, category, what the trigger listens for, everyday synonyms ("chime", "crowding", "terminus") and the name of the playlist assigned to it; every typed word must match, and a drawer folded by the author still shows its matches while a search is running.
  5. ?q= and ?cat= deep-link into the screen, so the Issues list can link to the trigger it is complaining about rather than to all of them.
  6. A custom rule can be duplicated outright; any trigger can copy its setup — playlist, LED signs, priority policy and prerequisite gates — onto one or more other triggers in a single undo step. The firing rule itself (thresholds, geofence, custom block) never travels, and a built-in is never duplicated in place, because the engine fires only the first enabled trigger of a type.

F-TRG-09 · Correctness guards on overlapping triggers

Description. As a passenger, I never hear contradictory or doubled announcements from overlapping trigger semantics: last-stop and journey-approaching-last-stop are mutually exclusive; stop-skipped's two detection sources resolve to one announcement per skipped stop; exit-side fires only for a side the author configured.

Acceptance criteria

  1. Enabling both final-stop triggers is prevented (or resolved) so no journey gets a double final-stop announcement.
  2. A skipped stop detected by both sources announces exactly once.
  3. exit-side stays silent for sides with no authored content.

E5 — Pronunciation (PRD-LEX)

F-LEX-01 · Pronunciation lexicon (respelling, IPA, say-as)

Description. As an author, I override how words are spoken via a lexicon — plain respelling, validated IPA (with graceful degradation on non-supporting suppliers), say-as, optionally restricted to one language — applied identically in engine render, preview and simulator, so a place name is pronounced right everywhere it is spoken.

Acceptance criteria

  1. A lexicon entry may use respelling, IPA or say-as, optionally scoped to one language.
  2. Invalid IPA is caught at entry; on suppliers without phoneme support the entry degrades gracefully (respelling / passthrough), never breaking synthesis.
  3. The same substitution applies in engine output, Creator preview and the journey simulator.

F-LEX-02 · Unicode-aware matching for non-ASCII names

Description. As an operator in Sweden or the Middle East, lexicon matching is word-boundary and case-insensitive and works for non-ASCII names (Malmö, Örebro, Arabic script) via Unicode-aware boundaries, so international stop names are first-class.

Acceptance criteria

  1. Matches occur on whole words only, case-insensitively.
  2. Entries for non-ASCII names (incl. Arabic script) match correctly; substrings inside longer words do not.

F-LEX-03 · Lexicon authoring aids (IPA palette, Listen, Approved)

Description. As an author who is not a phonetician, I get real help: a click-to-insert IPA symbol palette, live IPA validation/normalisation, worked one-click examples, per-entry Listen (real <phoneme> SSML on suppliers that support it, respelling elsewhere — matching engine behaviour), an Approved QA marker, and an author guide (docs/pronunciation-guide.md).

Acceptance criteria

  1. The IPA palette inserts symbols at the cursor; input is validated and normalised live.
  2. Listen synthesises with real phoneme SSML where supported, respelling elsewhere — the same rule the engine applies.
  3. Entries can be marked Approved and the marker is visible in the list.
  4. The author guide is linked from the screen.

F-LEX-04 · GTFS import with AI abbreviation expansion

Description. As an author starting a new contract, I import a GTFS feed (parsed client-side), which extracts stop/route/headsign names, expands abbreviations via the portal AI (RD → Road), and lets me review and edit before anything is added; imported entries carry a GTFS source tag.

Acceptance criteria

  1. The feed parses in the browser; stop, route and headsign names are extracted — whether the .txt files sit at the zip root or inside a folder.
  2. Abbreviations are expanded via the portal AI and shown for review; nothing is added without confirmation.
  3. Imported entries carry a GTFS source tag distinguishing them from hand-authored ones.
  4. The dialog states which AI provider will answer before the upload, and an author on a portal that names none can enter an endpoint, model and API key there — stored in that browser only, never written into the project and never published to a vehicle.
  5. A provider that is unconfigured, unreachable, out of quota or rejecting the key is reported as a sentence naming the endpoint and the fix — never a bare "fetch failed".
  6. Expansions come back in the fleet's own language: the dialog defaults to the project's voice language, the author can change it for this import, and the entries created are stamped with it. A Swedish feed yields "IP → Idrottsplatsen" and "CSK → Centralsjukhuset", not English translations; brand names (IKEA, ICA, ESS) are left unchanged and marked low confidence.

F-LEX-05 · Speak-it-in: say the name, get the phonetics

Description. As an author, I press 🎤 and say the name as it should be pronounced; the recording is analysed (Azure speech-to-text, phoneme granularity, unscripted) and the heard IPA plus a recognized-text respelling seed fill the builder's fields, with my own clip kept playable for an A/B against Listen. Recognition runs in the entry's language, falling back to the project's language — never silently English. For every selectable language except English (the only locale the speech service returns phoneme labels for), the IPA is drafted by that language's letter-to-sound rules — Germanic compounds split and stressed (Strandnorum → ˈstrandˌnuːrɵm, Högenorumsvägen → ˈhøːɡɛˌnuːrɵmsˌvɛːɡɛn, Hauptbahnhof → ˈhaʊptˌbaːnˌhoːf), Spanish/Italian/ Portuguese/Polish ending- or accent-based stress (Sevilla → seˈbiʎa), French nasals and silent finals (Montparnasse → mɔ̃tpaʁnas) — from the typed name when it matches what was heard, and the UI says the draft came from spelling rules. Without an Azure key or offline, the feature degrades to record-and-compare-by-ear.

Acceptance criteria

  1. Recording then analysing fills IPA and respelling fields from the spoken sample.
  2. The author's clip stays playable next to Listen for A/B comparison.
  3. With analysis unavailable (no key / offline), recording and playback still work and the degradation is communicated.
  4. A non-English entry (or a non-English project with no entry language picked) yields IPA drafts in that language — never English force-fits ("Strong. No room.") or an empty IPA field — and the UI states when the IPA came from spelling rules rather than the recording.

E6 — Speech Synthesis & Suppliers (PRD-VOX)

F-VOX-01 · 13 pluggable TTS suppliers

Description. As a platform owner, the TTS supplier is pluggable across 13 suppliers — Azure AI Speech, ElevenLabs, Acapela, Google Cloud TTS, Amazon Polly, ReadSpeaker, Cerence, CereProc, Picovoice Orca, self-hosted OpenAI-compatible Kokoro / Piper / eSpeak, plus Mock (silent, offline, for CI/load tests) — so voice quality, cost and offline capability are procurement choices, not architecture changes.

Acceptance criteria

  1. Switching supplier is configuration only; the pipeline renders through any of the 13.
  2. The self-hosted suppliers work with no internet access; Mock produces silent clips for CI/load tests.

F-VOX-02 · Capability descriptors drive predictable degradation

Description. As an author, each supplier declares a capability descriptor (ssml, prosodyVolume, phoneme, offline) so features degrade predictably: honoured where declared, dropped with a log line + authoring warning elsewhere; the Voices screen badges each supplier's capabilities.

Acceptance criteria

  1. SSML-dependent features (volume prosody, phoneme) are applied only on suppliers declaring them.
  2. On non-declaring suppliers the feature is dropped with an engine log line, and the Creator warns at authoring time.
  3. The Voices screen shows capability badges per supplier.

F-VOX-03 · Provider-aware voice pickers

Description. As an author, every voice picker (per playlist/element) is provider-aware: only voices valid for the active supplier are offered, catalogues are fetched live from each supplier, A/B audition plays a common sample line, playlists that pin a voice from another supplier show a warning with a one-click repoint — and an embedded decision guide (TTS market-research scorecard, benchmark floor, TCO scenarios) supports the choice. This prevents the "every announcement speaks George" failure of storing foreign voice IDs.

Acceptance criteria

  1. Pickers list only the active supplier's voices, fetched live.
  2. A/B audition speaks the same sample line in any two candidate voices.
  3. A playlist pinned to a voice invalid for the active supplier is flagged, with one-click repoint.
  4. The decision guide (scorecard, benchmark, TCO) is reachable from the picker.

F-VOX-04 · Resilient synthesis (retry, breaker, fallback clip)

Description. As a passenger, a TTS hiccup never silences the vehicle: synthesis retries with exponential backoff + jitter; a circuit breaker fast-fails and half-opens with a single probe; auth errors skip retries; and a pre-recorded fallback clip plays if all else fails — signs still render from text regardless.

Acceptance criteria

  1. Transient synth failures retry with exponential backoff and jitter.
  2. Repeated failures open the breaker (fast-fail); after the cool-down exactly one probe half-opens it.
  3. Authentication errors fail immediately without retries.
  4. When synthesis is impossible, the fallback clip is dispatched and LED output still renders.

F-VOX-05 · Automatic failover to a fallback supplier

Description. As an operator, a vendor outage speaks in another voice, not a tone: a configured fallback supplier (TTS_FALLBACK_PROVIDER or config.ttsFallbackProvider from the Creator) takes over while the primary's breaker is open, and per-provider health is tracked.

Acceptance criteria

  1. With the primary breaker open, new synthesis routes to the fallback supplier.
  2. When the primary recovers (breaker closes), synthesis returns to it.
  3. Health is tracked per provider and visible in engine telemetry.

F-VOX-06 · Speech rate via SSML prosody

Description. As an author, speech rate applies via SSML prosody on suppliers that support it (pitch exists at the SSML layer but is deliberately not authorable), so pacing is tunable without re-recording anything.

Acceptance criteria

  1. Configured speech rate audibly applies on SSML-capable suppliers.
  2. Non-SSML suppliers ignore rate predictably (log + warning per F-VOX-02).

E7 — Rendering, Caching & Pre-delivery (PRD-REN)

F-REN-01 · One clip + transcript per playlist, minimal TTS calls

Description. As a platform owner, the engine renders a playlist to one audio clip + one transcript, merging consecutive same-voice elements into a single TTS request (a voice or volume change splits the run) — so supplier calls, latency and cost stay minimal.

Acceptance criteria

  1. A playlist renders to exactly one dispatched clip and one transcript.
  2. Consecutive elements in the same voice/volume produce one TTS request.
  3. A voice or volume change starts a new synthesis run at that boundary.

F-REN-02 · Fleet-wide content-addressed cache with single-flight

Description. As a platform owner, an identical phrase is synthesised once for the whole fleet: a content-addressed layered cache (memory LRU → disk capped by CACHE_MAX_FILES → Azure Blob) serves repeats, and single-flight coalescing collapses concurrent identical requests — TTS cost scales with unique phrases, not vehicles.

Acceptance criteria

  1. The same text+voice+parameters yields the same cache key; a second render is a cache hit with no supplier call.
  2. Cache tiers fall through memory → disk → blob; the disk tier respects CACHE_MAX_FILES.
  3. N concurrent identical requests cause exactly one supplier call (single-flight).

F-REN-03 · Predictive pre-render of upcoming stops

Description. As a passenger, announcements are instant: the engine predictively pre-renders the next PRERENDER_LOOKAHEAD stops' announcements (deduped per journey/stop; volatile playlists skipped), so live triggers hit a warm cache with ~0 TTS latency.

Acceptance criteria

  1. With lookahead N, the next N stops' announcements are rendered ahead of arrival.
  2. Pre-render dedupes per journey/stop; volatile (live-data) playlists are skipped.
  3. The subsequent live trigger is served from cache (ttsMs=0).

F-REN-04 · Closed-corpus pre-push to on-vehicle caches

Description. As an operator of vehicles on weak links, when a journey's stop list becomes known, the engine (opt-in PREPUSH_ENABLED) renders the journey's whole announcement corpus and delivers the clips to players' on-device caches on …/pis/0/tts/cache; the pushed clipKey is byte-identical to a live publish, so live announcements can go out key-only — hundreds of bytes instead of audio. A degraded-link aid, explicitly not an offline guarantee.

Acceptance criteria

  1. With pre-push enabled, learning a journey's stop list triggers rendering and pushing of its full corpus.
  2. Pushed clipKeys are byte-identical to those of live publishes for the same content.
  3. A live announcement whose clip was pre-pushed dispatches key-only (payload of hundreds of bytes).
  4. With pre-push disabled, live dispatch behaviour is unchanged.

F-REN-05 · Resilient pre-recorded audio fetching

Description. As an author using recorded clips (audio-file/audio-ref), fetches retry with backoff and cache fleet-wide by URL; a permanently failing clip drops just that clip, not the whole announcement.

Acceptance criteria

  1. Clip fetches retry with backoff on transient failure and are cached by URL.
  2. A permanent fetch failure omits only that element; the rest of the announcement plays.

F-REN-06 · Never speak an announcement with a gap

Description. As a passenger, I never hear "the next stop is … [silence]": an announcement with unresolved required variables is droppable rather than spoken with a hole (SKIP_INCOMPLETE_ANNOUNCEMENTS); optional variables never block; unknown {tokens} are logged by name so authors can fix them.

Acceptance criteria

  1. With skip enabled, a required variable that cannot resolve drops the announcement (and logs why).
  2. Unresolved optional variables never block or distort dispatch.
  3. Unknown tokens are logged by name.

E8 — LED Signage, Vendor-Independent (PRD-LED)

Feature order follows the PRD's thematic grouping, so F-LED-11/-12 (authored restyling rules, vehicle roster) sit with the destination-list work they belong to, ahead of F-LED-09/-10. Nothing in the range is missing.

F-LED-01 · Interior sign bitmap for every interior announcement

Description. As a passenger who is deaf or hard of hearing, every interior-routed announcement also renders visually: a 16×144 amber 1-bpp bitmap on …/pis/0/display, carrying the same traceId as the audio, centred when it fits and flagged scroll when wider — audio and sign are one auditable event.

Acceptance criteria

  1. Every interior-routed announcement publishes an interior bitmap with the audio's traceId.
  2. Text narrower than 144 px is centred; wider text is flagged scroll.
  3. The bitmap is 16×144, 1-bpp, amber.

F-LED-02 · Exterior destination signs (fit-first, scroll-last)

Description. As a waiting passenger, the destination signs (front 192 / side 160 / rear 48 px × 24 px, configurable) always show a readable route number that never scrolls, while the destination shrinks/condenses to fit and scrolls only as a last resort; frames publish retained on route/destination change so a late-joining sign shows the current state.

Acceptance criteria

  1. The route number renders fixed and never scrolls on any face.
  2. Destination text scales/condenses to fit by default; scrolling engages only when no fit exists.
  3. Face resolutions are configurable; defaults are 192/160/48 × 24.
  4. Frames publish retained on route or destination change.

F-LED-03 · Dual-vendor raw frames from one render pass (FF + Hanover)

Description. As an operator of a mixed Luminator + Hanover fleet, the same bitmaps emit as raw Mobitec FF frames (board 1463-L, graphic font w, spec checksum, interior scroll frames 0xA5/0xD5) and Hanover HCPS/SuperX frames (STX…ETX + 2-char checksum, {\pic} raster; wide interior text uses the SuperX \sl repeat-forever hardware scroll) on parallel topics — one render pass drives both vendors. Frames are signature-deduped: republished only when pixels, address or protocol actually change.

Acceptance criteria

  1. Each rendered surface publishes both an FF frame and a Hanover frame on their parallel topics.
  2. Checksums and raster packing are byte-exact against both vendors' documented worked examples.
  3. An unchanged frame (same pixels/address/protocol) is not republished.
  4. Hanover interior text wider than the panel uses the \sl hardware scroll.

F-LED-04 · Per-address fan-out for sign controllers

Description. As an integrator wiring a sign controller, every raw-frame path also fans out per bus address (…/ff/{address}, …/hanover/{address} — fixed faces and template signs alike), so a controller subscribes to exactly its own address; re-addressing or removing a sign clears its stale retained topic.

Acceptance criteria

  1. Every raw frame is also published on its per-address topic for both protocols.
  2. A controller subscribing to one address receives only that sign's frames.
  3. Re-addressing or removing a sign clears the old retained per-address topic.

F-LED-05 · Matrix LED template authoring & rendering (MatrixRenderer)

Description. As an author, I design pixel-accurate LED layouts on the Luminator MatrixRenderer contract (Display → Cycles → Layout → Elements): six element types (Text, Image, Rectangle, Line, Ellipse, Symbol) with overflow/alignment/scroll, text alternations (one element rotating languages or messages on a timer), blink, colour expressions and font ladders; displays with condition-gated cycles; a vehicle roster (Ultima models, resolution, position, address, colour mode, per-sign wire protocol: ff | hanover); symbol mappings and colour rules. The engine rasterises with real FNT fonts and emits mono/RGB FF (protocol 03090) or Hanover frames, gated by DISPLAY_TEMPLATE_MODE.

Acceptance criteria

  1. All six element types author with their overflow, alignment, scroll, alternation, blink, colour-expression and font-ladder options.
  2. Displays hold condition-gated cycles selecting layouts; the roster models vehicles/signs incl. per-sign wire protocol.
  3. The engine rasterises templates with real FNT fonts and emits the frames for each sign's protocol, gated by DISPLAY_TEMPLATE_MODE.
  4. Verification hook: parity tests keep Creator and engine models/renderers in lockstep.

F-LED-06 · Trigger-bound layouts per vehicle face

Description. As an author, a trigger (built-in or custom) optionally binds a layout to a vehicle face for the duration of the fire (e.g. "STOPPING" while the stop request is active); otherwise the Display's condition-gated cycles select from globalState — the same fact catalogue that drives audio.

Acceptance criteria

  1. A trigger with a bound layout takes over the chosen face while it fires and releases it afterwards.
  2. Without a binding, cycle conditions over globalState select what shows.
  3. The fact vocabulary equals the audio fact catalogue.

F-LED-07 · RTL rendering: Arabic & Hebrew on every surface

Description. As a passenger reading Arabic or Hebrew, LED text renders correctly on every surface: RTL is auto-detected (no authoring flag), Arabic contextual shaping + bidi run at one chokepoint (src/shared/fnt.ts), missing code points fall back to Unifont glyphs, and RTL text scrolls left-to-right so the reader meets the first word first.

Acceptance criteria

  1. Arabic renders with correct contextual letterforms; mixed-direction strings order per bidi.
  2. RTL needs no authoring flag — detection is automatic.
  3. Code points missing from the primary font render via Unifont fallback, never as blanks.
  4. RTL text scrolls left-to-right.

F-LED-08 · Pre-programmed destination lists (WYSIWYG on the real panel)

Description. As an author, I maintain multiple named destination lists (code → what every sign face shows, optionally scoped by line code) with duplicate-code detection and GTFS headsign seeding. I edit WYSIWYG on the real panel — typing straight into the rendered sign at true resolution/font through the engine's renderer, cross-checked live against the vehicle's other panels, with text/shape/symbol/image elements and per-destination "own arrangement" overrides. I print a signed approval review sheet, and run a Check fit sweep with an actionable report. The engine resolves a code-only pis/0/destination selection against the active list; the list publishes retained on …/pis/0/list/destinations when DESTINATION_LIST_PUBLISH=true.

Acceptance criteria

  1. Multiple named lists; duplicate codes are detected; GTFS headsigns can seed entries; line-code scoping works.
  2. The WYSIWYG editor renders through the engine's renderer at real resolution and font, with live cross-check on the other panels and per-destination overrides.
  3. The review sheet prints with a sign-off block; Check fit sweeps every entry and reports failures actionably.
  4. A code-only destination selection resolves against the active list in the engine; with DESTINATION_LIST_PUBLISH=true the list publishes retained.

F-LED-11 · Authored restyling rules: abbreviations, symbols, route colours

Description. As an author, sign text is restyled by rules applied identically in Creator preview, dashboard and engine: abbreviations (whole-value or per-word; "always" vs "only when tight"), symbol mappings (field eq/contains/regex → built-in pictogram or uploaded image), and route colours (per-line background/text/outline, importable from GTFS route_color without overwriting hand-picked rows).

Acceptance criteria

  1. Abbreviation rules support whole-value and per-word modes and the always / only-when-tight policies.
  2. Symbol mappings match by eq/contains/regex and render built-in pictograms or uploaded images.
  3. Route colour rules apply per line; GTFS import fills colours without overwriting manual rows.
  4. All three rule families produce identical results in Creator preview, dashboard and engine output.

F-LED-12 · Vehicle roster UX at fleet quality

Description. As an author commissioning vehicles, the roster works at fleet scale: catalog-first panel picker (Luminator models), auto-assigned FF addresses, per-sign display binding vs announcement mirror, one-click seeders (standard vehicle; EMEA / US / Vilnius worked examples), "Assign default templates" size matching, and Validate fit — sweeping every GTFS line, destination and stop name through the vehicle's templates with an "add abbreviation and re-validate in place" fix loop.

Acceptance criteria

  1. Panels are picked from the Luminator model catalog; FF addresses auto-assign.
  2. Each sign selects display binding or announcement mirror; seeders create the documented example vehicles in one click.
  3. "Assign default templates" matches templates to panel sizes.
  4. Validate fit sweeps all GTFS lines/destinations/stop names and lets me add an abbreviation and re-validate without leaving the report.

F-LED-09 · Pixel-identical browser sign clients

Description. As a technician or reviewer, any browser shows exactly what the hardware shows: browser surfaces rasterise with the same font and shared LED core the engine ships (/led-font.json, @shared/* verbatim). /sign turns any display device into one physical sign face; /vehicle shows every exterior panel from one shared journey state; /signs is a worked-case index generated from the client's own demo config (so it cannot drift); /player/led is the player's LED half, live over the credential-safe SSE bridge — no broker address on the device.

Acceptance criteria

  1. Browser rendering uses the engine's font and shared LED modules — previews/replays/clients are pixel-identical to hardware frames.
  2. /sign renders one face; /vehicle renders all exterior faces from one journey state; /signs lists worked cases generated from live client code.
  3. /player/led follows live state via the SSE bridge with no broker credentials or address on the device.

F-LED-10 · Interior TFT preview & energy facts on signs

Description. As an operator, the interior TFT (1920×610) is previewable in the Monitor drawer with an expand overlay (the drawer alone is too small to judge it), and battery/energy telemetry surfaces on signs via {stateOfCharge} facts, Energy LED situations and battery presets/symbols.

Acceptance criteria

  1. The Monitor drawer previews the TFT; the expand overlay shows it at judgeable size.
  2. {stateOfCharge}/charging facts drive sign content via situations, presets and battery symbols.

E9 — Dispatch & Arbitration (PRD-DSP)

F-DSP-01 · One announcement at a time: priority arbitration

Description. As a passenger, announcements never talk over each other: exactly one plays per vehicle at a time. Every offer resolves to play · interrupt · queue · drop, driven by authorable priorities 1–11 (flat or nested shape) with per-type defaults. An interrupt aborts the superseded render so its audio is never published; the queue is priority-ordered, bounded at 8, and sheds the lowest-priority tail.

Acceptance criteria

  1. At no time are two announcements dispatched concurrently to one vehicle.
  2. A higher-priority offer interrupts; equal/lower queues; overflow drops the lowest-priority tail (queue bound 8).
  3. An interrupted render is aborted — its audio never reaches the wire.
  4. Priorities 1–11 are authorable per trigger (flat or nested), with per-type defaults applied otherwise.

F-DSP-02 · ADT 4.x audio payloads (contract-tested)

Description. As an integrator, audio publishes as ADT 4.x (base64 MP3/OPUS, integer speaker levels) conforming to the transHub AsyncAPI spec and validated by contract tests; payloads carry the clipKey for the on-device cache.

Acceptance criteria

  1. Dispatched payloads validate against the ADT 4.x transHub contract (contract tests pass).
  2. Speaker levels are integers; audio is base64 MP3/OPUS.
  3. Every payload carries its clipKey.

F-DSP-03 · Production-safe render scoping

Description. As an operator connecting the engine to a live fleet, nothing plays until I opt vehicles in: the engine renders only for the served set (RENDER_SCOPE=selection, retained control topic; empty set = silence). all exists for load tests and on-vehicle deployments only; an invalid value fails at startup rather than guessing.

Acceptance criteria

  1. With RENDER_SCOPE=selection and an empty served set, a live feed produces zero announcements.
  2. Adding a vehicle to the served set starts rendering for it; removing stops it.
  3. RENDER_SCOPE=all renders for every vehicle (documented for load-test/on-vehicle use).
  4. An invalid RENDER_SCOPE value aborts startup with a clear error.

F-DSP-04 · ICU announcement-slave audio (external/internal topics)

Description. As an integrator whose vehicle audio is driven by the ICU, the engine also publishes each rendered announcement on the ICU's own per-channel topics — …/pis/0/announcement/internal_audio/change (interior, left channel) and …/external_audio/change (exterior, right channel) — as {"mp3": "<base64>"}, retained at QoS 1, which the ICU plays at its preset volume. It is a mirror of the ADT dispatch, and opt-in (default off) because a vehicle that already plays the ADT message would otherwise hear every announcement twice. As an author I switch it per fleet in the Creator (Settings → Audio output: Engine default · On · Off), published with the config — so moving a fleet to ICU-slave audio takes a publish, not an engine redeploy.

Acceptance criteria

  1. With the output enabled, an interior announcement publishes to …/internal_audio/change, an exterior one to …/external_audio/change, and a both playlist mirrors the same bytes to both.
  2. Each clip is published on both the fleet-scoped topic and the spec's unscoped pis/0/announcement/… (same payload and flags); ICU_AUDIO_UNSCOPED=false keeps the scoped one only, an already-unscoped pattern publishes once, and a second vehicle on the unscoped topic raises a warning.
  3. The payload contains exactly one field, mp3, whose bytes are identical to the ADT dispatch's.
  4. Messages are published retained at QoS 1 (both overridable); an invalid QoS aborts startup.
  5. Default off: with ICU_AUDIO_ENABLED unset and the config saying nothing, nothing is published to any of these topics.
  6. The Creator's switch has three states and survives publish/import: On enables the output even where ICU_AUDIO_ENABLED is off, Off disables it even where the env enables it, and Engine default leaves the decision to the env — and stays reachable after the switch has been touched.
  7. A QoS>0 publish attempted while the broker link is down is rejected, not queued for replay.
  8. A failed ICU publish is logged and never prevents the ADT dispatch, its LED output or its proof-of-play record; one address failing still counts the channel as delivered if the other succeeded.

E10 — Onboard Playback (PRD-PLY)

F-PLY-01 · One playback contract, three players

Description. As a fleet owner, three interchangeable players (Go, Python, browser) implement one contract: subscribe …/pis/0/tts, queue and play strictly one clip at a time, ack on engine/played when the clip finishes ({traceId, tenant, vehicleId, dispatchedAt, playedAt, status}), never block the network loop, and time out wedged clips — so hardware choice never changes behaviour.

Acceptance criteria

  1. Each player plays queued clips strictly one at a time in arrival order.
  2. The ack publishes on completion (not receipt) with all six contract fields.
  3. Audio playback never blocks the MQTT loop; a wedged clip times out and the queue proceeds.
  4. All three players pass the same behavioural expectations.

F-PLY-02 · On-vehicle clip cache, key-only replay, expiry

Description. As an operator on metered links, players maintain an on-vehicle clip cache (Go/Python: LRU directory, default 64 MB; browser: IndexedDB, last 150 clips): pre-pushed clips are stored by clipKey and never played on arrival; a key-only payload replays cached bytes (hundreds of bytes instead of ~100 KB); a clip past expiryDateTime (default 300 s after dispatch) is dropped and acked expireda late announcement is worse than a missed one.

Acceptance criteria

  1. Pre-pushed clips are cached by clipKey and produce no sound on arrival.
  2. A key-only dispatch plays the cached bytes; a key-only dispatch for an uncached key is handled gracefully (no crash, no silence-hang).
  3. A clip arriving/starting past its expiryDateTime is dropped and acked expired, never played late.
  4. Ack status takes exactly the values played / failed / expired; caches respect their bounds (64 MB LRU / 150 clips).

F-PLY-03 · Deployable players: containers + zero-install browser

Description. As a field technician, headless players ship as multi-arch containers (amd64 + arm64, outbound-only MQTT, --device /dev/snd), and the zero-install browser player is deep-linkable — ?tenant=&vehicle= and the path form /player/v/<tenant>/<vehicle> used by QR hand-offs (immune to query stripping) — wake-locked, with volume from speakers.INTERNAL.

Acceptance criteria

  1. Go/Python player images run on amd64 and arm64 with outbound-only MQTT and ALSA device access.
  2. The browser player opens pre-bound to a vehicle via both the query and path deep-link forms; the QR code uses the path form.
  3. The browser player holds a wake lock while active and applies speakers.INTERNAL volume.

E11 — Proof of Play, Evidence & Compliance (PRD-AUD)

F-AUD-01 · Append-only audit trail on one timeline

Description. As a Compliance Officer, everything the fleet did is on one append-only timeline — audio (dispatched), exterior (sign changed), played (vehicle-confirmed) — each record carrying vehicle, GPS, time, trigger, stop, route, destination, journey ref, transcript and the lexicon-adjusted spoken text, language, voice, routing, volume, timing, cache stats and display status — evidence of what passengers actually heard and saw.

Acceptance criteria

  1. Dispatches, exterior sign changes and vehicle confirmations append records of the three types on one queryable timeline.
  2. Records carry all catalogued fields, including both the raw transcript and the lexicon-adjusted spoken text.
  3. The trail is append-only — no update or delete path exists.

F-AUD-02 · Ack correlation & the coverage metric

Description. As an auditor, played acks correlate to dispatches by traceId, and coverage is defined as confirmed ÷ correlatable dispatches — uncorrelated acks are excluded, and a later success supersedes a failed retry, so the number is defensible.

Acceptance criteria

  1. Each played ack joins its dispatch by traceId.
  2. Coverage = confirmed ÷ correlatable; acks with no matching dispatch don't count.
  3. A successful retry supersedes the earlier failure of the same announcement in the metric.

F-AUD-03 · Query, aggregate & four views with proof-pack export

Description. As an auditor, I slice the trail by tenant, vehicle, route, destination, journey, trigger, surface, free text and time; group by journey/route/destination/date/hour/vehicle — computed server-side over the full filtered set — through four dashboard views (Groups, Table, Map, Replay), a KPI summary, and CSV/JSON proof-pack export. Replay is labelled a reconstruction, not a recording.

Acceptance criteria

  1. All listed filters and group-bys work and compose; aggregation covers the full filtered set, not the visible page.
  2. Groups, Table, Map and Replay render the same filtered data; the KPI summary reflects it.
  3. CSV and JSON exports contain the filtered set; Replay carries the reconstruction label.

F-AUD-04 · Certified coverage report with integrity hash

Description. As a Compliance Officer, /proof/report produces a certified coverage report (JSON / CSV / print HTML) with per-route/vehicle/day breakdowns, an exceptions table, a basis disclaimer, an operator sign-off block and a SHA-256 integrity hash; with GTFS loaded, schedule-based expected coverage flags stops that were never even attempted.

Acceptance criteria

  1. The report renders in all three formats with breakdowns, exceptions, disclaimer and sign-off block.
  2. The report embeds a SHA-256 integrity hash over its content; recomputation verifies it.
  3. With GTFS, expected coverage compares schedule to evidence and flags never-attempted stops.

F-AUD-05 · Durable persistence with store-and-forward

Description. As an operator of vehicles through tunnels and dead zones, proof rows persist per tenant to Postgres (PROOF_DB_ENABLED, batched, best-effort — the local file stays primary) and store-and-forward: while the DB is unreachable, rows buffer and retry with capped backoff, probing at most once a minute (metered-SIM friendly) — an hour offline uploads the whole hour on reconnect. The buffer is bounded by PROOF_BUFFER_MAX (default 10 000 ≈ a service day); overflow sheds oldest (the file keeps them) and the shed count is reported on recovery. A sink failure never blocks the render pipeline.

Acceptance criteria

  1. With the DB up, rows persist batched per tenant; the local file remains the primary record.
  2. With the DB down, rows buffer; reconnection uploads the backlog; link probes are ≤ 1/minute.
  3. Buffer overflow sheds oldest rows, keeps them in the file, and reports the shed count on recovery.
  4. No database outage ever delays or drops an announcement.

E12 — Operations, Monitoring & Diagnostics (PRD-OPS)

F-OPS-01 · Served-set control from the dashboard

Description. As a Fleet Operator, I control which vehicles the engine serves from the dashboard; the selection is retained (survives engine restart) and re-syncs bidirectionally, and a tenant scope narrows the bridge at runtime — safe, explicit opt-in of real vehicles.

Acceptance criteria

  1. Adding/removing vehicles in the dashboard changes the engine's served set immediately.
  2. The selection survives an engine restart (retained control topic) and re-syncs both ways.
  3. Setting a tenant scope narrows the upstream bridge at runtime.

F-OPS-02 · Health telemetry, metrics & operational alerts

Description. As an operator, the engine publishes retained health telemetry (engine/health: throughput, event rate, loop lag, RSS, cache hit rate, in-flight/queued/dropped/interrupted, pre-render, TTS breaker/fallback/retry) and per-announcement metrics (engine/metrics: resolve/render/TTS/publish timings + GPS); the dashboard shows stat cards, sparklines, latency breakdowns, an event map and operational alerts (breaker open, drops, silent vehicles) — I see trouble before passengers do.

Acceptance criteria

  1. engine/health publishes retained with the catalogued fields; engine/metrics carries per-announcement stage timings and GPS.
  2. The dashboard renders stat cards, sparklines, latency breakdowns and the event map from live telemetry.
  3. Alerts raise on breaker-open, announcement drops and silent vehicles, and clear when the condition ends.

F-OPS-03 · Bounded retained fleet directory

Description. As an operator of a large broker, the vehicle picker populates from a retained fleet directory (engine/fleet) without subscribing the whole fleet — capped by FLEET_MAX_BYTES so the payload can never outgrow the broker's max packet size and freeze the picker (the "— → ?" wall).

Acceptance criteria

  1. The picker fills from the retained directory alone — no fleet-wide subscription from the browser.
  2. The directory payload never exceeds FLEET_MAX_BYTES; over-limit content is truncated by policy, and the picker keeps working.

F-OPS-04 · Test-signal injection & full journey simulation

Description. As an operator I inject test signals (stop request, doors, off-route, exit side, crowding, alert, alarm, destination override) on real PIS topics; as an author I simulate a full GTFS journey — the bus driven along the real route shape on a map at 0.5–4× speed, a timeline of modelled PIS events with custom triggers interleaved, each event resolving to the exact spoken text (real-TTS audition in the selected voice), a live LED panel of the vehicle's signs at the simulated moment, and manual overrides (doors, stop request, journey state, destination code) — so a whole service day is testable at a desk.

Acceptance criteria

  1. Each test signal publishes on the real PIS topic and drives the engine exactly like vehicle data.
  2. The simulator drives the route shape on a map at 0.5–4×; the event timeline interleaves custom triggers.
  3. Each timeline event shows its resolved spoken text and auditions via real TTS in the selected voice.
  4. The live LED panel tracks the simulated moment; manual overrides work during the run.

F-OPS-05 · Lean, scoped upstream bridge

Description. As a platform owner, the upstream bridge follows the dashboard selection, runs lean fleet discovery (retained journeystate + destination only), tenant-scopes its traffic, dedups byte-identical re-publishes, and publishes retained bridge health — upstream load stays proportional to what is actually being served.

Acceptance criteria

  1. Full per-vehicle subscription exists only for selected vehicles; discovery uses the two lean retained topics.
  2. Byte-identical upstream re-publishes are not forwarded.
  3. Bridge health publishes retained; tenant scoping filters upstream traffic.

F-OPS-06 · Same-origin MQTT relay for browsers

Description. As a security-conscious operator, browsers reach the broker through a same-origin relay on the portal port by default (/mqtt-proxy — credentials stay server-side, only :8080 need be reachable, DASHBOARD_WS_MAX_CLIENTS bounds concurrency), with direct WebSocket as opt-out; the broker chip names the transport, target and failure reason, so connectivity issues are diagnosable at a glance.

Acceptance criteria

  1. By default, browser MQTT flows via /mqtt-proxy; no broker credentials or address reach the client.
  2. Relay client count is bounded by DASHBOARD_WS_MAX_CLIENTS.
  3. The direct-WebSocket path works as explicit opt-out.
  4. The broker chip shows transport, target, and the failure reason when disconnected.

F-OPS-07 · One-command diagnosis (doctor / /diag)

Description. As anyone facing "no announcements", npm run doctor (or GET /diag) walks the chain — broker TCP → MQTT session → WS listener → enabled triggers → engine heartbeat (retained, so a live engine can't read dead) → served set → live PIS traffic (live publishes, not retained backlog) — and names the first broken link instead of leaving me to bisect.

Acceptance criteria

  1. The check runs the documented chain in order and reports the first failing link by name.
  2. A healthy system reports all-green; heartbeat is judged on retained liveness; PIS traffic is judged on live publishes only.
  3. Both the CLI and the HTTP form return the same verdict.

F-OPS-08 · Process hygiene: no doubled announcements

Description. As a developer/operator, npm run stop kills engine, player, simulator and dashboard but never the broker; each app auto-clears its previous instance on start — re-runs never double up, so no doubled announcements from stray processes.

Acceptance criteria

  1. npm run stop terminates the four app processes and leaves the broker running.
  2. Starting an app kills its previous instance first; two starts in a row leave exactly one instance.

E13 — Deployment & Environments (PRD-DEP)

F-DEP-01 · One multi-arch image for every host

Description. As a platform owner, one multi-arch image (amd64 + arm64, node:22-slim, pure-JS dependencies) serves back-office hosts and ARM onboard units alike — only the environment differs, so there is one artefact to build, scan and trust.

Acceptance criteria

  1. The image manifest carries amd64 and arm64; both run the engine.
  2. No native-compiled dependencies (pure JS) — the same image works unmodified on both architectures.

F-DEP-02 · Back-office topology & continuous deployment

Description. As an operator, the back office runs a single long-running replica per inbound stream (per-vehicle state in memory), config from Postgres, served set from the dashboard; git push main auto-deploys to the Azure VM under systemd supervision.

Acceptance criteria

  1. One engine replica per inbound stream serves the fleet with config from Postgres and the dashboard-controlled served set.
  2. A push to main deploys to the VM automatically; services come back under systemd supervision.

F-DEP-03 · On-vehicle deployment (self-contained stack)

Description. As an operator wanting true offline capability, the whole stack (broker + engine + player) runs on one onboard unit via docker-compose.vehicle.yml: RENDER_SCOPE=all for the one local bus, file-first config with optional OTA via CONFIG_API_URL + retained notify, persistent TTS-cache and proof volumes, memory-bounded (512 MB), outbound-only MQTT behind NAT.

Acceptance criteria

  1. The compose file brings up broker, engine and player on one unit; the local vehicle announces with no WAN.
  2. Config is file-first; when CONFIG_API_URL is set, a retained notify pulls new config over the air.
  3. TTS cache and proof survive restarts (volumes); memory stays within the 512 MB bound; MQTT is outbound-only.

F-DEP-04 · Offline bundle export & import

Description. As an operator preparing a no-cloud vehicle, the Creator exports a per-route bundle (/export/bundle, token-gated; zip schema creatorstudio-offline-bundle 1.0.0 — clips as playable .mp3, engine tts-cache seed, config, manifest) with a free Estimate step that itemises what still needs a live link (requiresLiveData: connections, service alerts, clock/countdown variables). Clip bytes and clipKeys are byte-identical to live publishes (shared enumeration/render path with the pre-push); npm run import:bundle seeds a vehicle's player and engine caches, with optional --verify hashing.

Acceptance criteria

  1. Estimate runs without cost and lists the requiresLiveData items for the route.
  2. The exported zip matches schema 1.0.0: playable clips, tts-cache seed, config, manifest.
  3. Bundle clip bytes and clipKeys are byte-identical to what the engine publishes live.
  4. import:bundle seeds player and engine caches; --verify checks hashes.

F-DEP-05 · Limited connectivity as a supported spectrum

Description. As a solution architect, limited connectivity is a documented, supported spectrum (docs/limited-connectivity.md): A central engine + on-vehicle caches (a fully pre-pushed two-announcement stop costs < 1 KB of announcement traffic), B on-vehicle engine for true offline (offline TTS via self-hosted suppliers, proof store-and-forward), C offline bundle for no-cloud — each with its bandwidth budget and an explicit "still needs a live link" list. A late announcement is worse than a missed one: in a dead zone, pattern A's announcement is missed, never late (expiry enforced at the player).

Acceptance criteria

  1. All three patterns are documented with bandwidth budgets and live-link caveats, and are deployable from shipped artefacts.
  2. Pattern A: a fully pre-pushed stop's announcements cost < 1 KB of announcement traffic.
  3. In a dead zone under pattern A, expired clips are dropped and acked expired — nothing plays late.

F-DEP-06 · Corporate TLS trust (--use-system-ca)

Description. As a developer behind TLS-inspecting infrastructure (Zscaler), all Node entry points run with --use-system-ca, so corporate roots from the OS store are trusted and cloud calls work without per-process certificate hacks.

Acceptance criteria

  1. Every Node entry point (engine, dashboard, scripts) launches with --use-system-ca.
  2. HTTPS calls succeed behind a TLS-inspection proxy whose root is in the OS store.

E14 — Security (PRD-SEC)

F-SEC-01 · Token-gated mutating & cost-bearing routes

Description. As a platform owner, mutating or cost-bearing portal routes (POST /config, /tts-preview, /gtfs/import, /player/ack, …) require Authorization: Bearer $DASHBOARD_TOKEN when set, compared in constant time; reads stay open — write and spend are protected, observation is free.

Acceptance criteria

  1. With the token set, mutating/cost-bearing routes reject requests without the correct bearer token (401/403).
  2. Token comparison is constant-time.
  3. Read-only routes work without a token.

F-SEC-02 · Verified publish identity

Description. As an auditor, published_by on a version derives from a server-validated token when present, and every version row records published_by_verified — a self-asserted label can never masquerade as evidence of a person.

Acceptance criteria

  1. With a valid token, the version row's identity is server-derived and marked verified.
  2. Without one, any client-supplied label is stored with published_by_verified=false.

F-SEC-03 · No broker credentials in the browser

Description. As a security reviewer, broker credentials stay server-side (the same-origin MQTT relay), and the Creator bundle ships no keys — runtime configuration is injected as env, so a bundle leak leaks no secrets.

Acceptance criteria

  1. No broker credential or address appears in served browser bundles or client-visible config.
  2. Browser MQTT connectivity works via the relay with credentials held only by the server.

F-SEC-04 · Open risks tracked, not hidden

Description. As a stakeholder, known security gaps are explicit, owned and scheduled — not buried: broker ws:// + anonymous access and QoS-0 acks (D-16/D-17) are deferred to Luminator Suite integration (wss, credentials, ACLs, ack QoS); Azure key rotation (D-18) and the legacy app's dev broker password (D-19) remain open; real user login awaits a suite identity provider.

Acceptance criteria

  1. Each open risk is registered (FUNCTIONAL-SPEC §10) with its deferral target.
  2. Documentation states that transport hardening blocks real-vehicle evidentiary use of proof-of-play.

E15 — Quality Requirements (NFR)

Cross-cutting, measurable quality criteria. [M] = measured, see TEST-REPORT.md / SCALING.md. In ADO these map to one "Quality/Performance" feature with each row a test case, or to acceptance criteria on the features they constrain.

ID Quality requirement Acceptance criterion (testable)
NFR-PERF-01 Cache-hit announcement latency Dominant case ≈ 2 ms with ttsMs=0 [M]
NFR-PERF-02 Cold synth of a unique phrase ≈ 1.0 s, incurred once per phrase fleet-wide [M]
NFR-PERF-03 Publish → fleet live ≈ 1 s via LISTEN/NOTIFY hot-swap
NFR-SCAL-01 Fleet scale on 2 vCPU / 4 GB 2000 vehicles: p50 loop lag 29 ms, RSS 250 MB, zero drops [M]; ceiling ~3000–3500
NFR-SCAL-02 Multi-tenant isolation model One engine+bridge per tenant; engine reads only its (tenant, fleet) row
NFR-COST-01 TTS cost model Cost scales with unique phrases, not vehicle count (cache + single-flight)
NFR-AVAIL-01 Supplier outage tolerance Retry → breaker → failover supplier → fallback clip; signs still render — no dropped announcement
NFR-AVAIL-02 Broker outage tolerance No crash on missing broker; unattended reconnect everywhere
NFR-RES-01 Memory under sustained load Flat RSS: LRU + disk cap + idle context eviction [M]
NFR-SAFE-01 Safe-by-default fleet connect Connecting to a live fleet announces nothing until vehicles are opted in
NFR-PORT-01 Player portability x86-64 + ARM64, headless, outbound-only behind NAT
NFR-OPS-01 Cloud-free operation Full stack from empty machine via db:start + stack:local

Appendix A — Deriving test cases from this backlog

  1. One criterion ⇒ at least one test case. Number test cases TC-<feature>-<criterion>[a,b,…] (e.g. TC-PLY-02-3 = the expiry criterion of F-PLY-02).
  2. Given/When/Then maps directly: Given → test preconditions/setup, When → steps, Then → expected result. Criteria without explicit GWT phrasing state an invariant — test it with one positive and one negative case.
  3. Negative cases are first-class. Fail-closed behaviours (gates F-TRG-04, render scope F-DSP-03, expiry F-PLY-02, publish gate F-PRJ-03, token gate F-SEC-01) deserve the negative test more than the positive one.
  4. Reuse the shipped harnesses: the journey simulator (F-OPS-04) and test-signal injection for end-to-end trigger/announcement cases; living demos (proof:demo, ff:demo, led:demo, multi:demo, bridge:test, config:test, prerender:demo) as scripted integration scenarios; npm run doctor as the environment precondition check before any E2E run.
  5. Don't re-test what CI pins. Criteria marked Verification hook are enforced by parity guards/contract tests on every build (1698 automated tests; engine 92.5 % / dashboard 92.6 % / Creator 90.7 % line coverage). Point ADO test cases at manual/E2E risk instead — see Appendix B.

Appendix B — Known gaps (do not write passing test cases against these)

From FUNCTIONAL-SPEC.md §10 / PRD §6 — still open, kept visible as PO-accepted debt:


Backlog compiled from PRD.md (requirement catalogue), FUNCTIONAL-SPEC.md (baseline acceptance criteria), SCREENS.md (behaviour as shipped) and TEST-REPORT.md (verification inventory). Keep this document in lockstep with PRD.md: a new PRD row gets a backlog entry here before its ADO ticket is cut.