# 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`](../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-01` ⇔ `PRD-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 `clipKey`s 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 `expired` — **a 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 `clipKey`s 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 `clipKey`s 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:

- Transport hardening (wss, credentials, ACLs, QoS ≥ 1 acks — D-16/D-17) is deferred to Luminator
  Suite integration and **blocks real-vehicle evidentiary use** of proof-of-play.
- No player drives the exterior loudspeaker (needs unit-specific amplifier enablement).
- `requiresDirection` gate and the `bus-type` trigger await upstream feed signals.
- Custom triggers bypass the universal gates/settle/debounce (D-13).
- No audio ducking/cross-fade on interrupt; no gapless MP3 concatenation; players ignore
  multi-clip `audio[]`.
- Geofence `entering`/`leaving` collapse to `within`/`outside` semantics (D-05).
- No end-to-end automated run of Go/Python players against a broker; FF/Hanover frames never driven
  into physical signs; report integrity hash tested at unit level only.
- Creator sign-in awaits a suite identity provider (publish identity rows honestly unverified).

---

*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.*



