# CreatorStudio Suite — System Functional Description

**Document type:** System Functional Description (SFD) · **Status:** Baseline v1.0 · **Date:** 2026-07-14
**Owner:** System Design & Product · **Applies to:** `feature/polygon-edit` @ `4c3ee35`

---

## About this document

This is the **functional** specification: *what the system does, for whom, and how you would know it worked.* It is deliberately distinct from its two siblings:

| Document | Answers |
| --- | --- |
| **This document** | What are the capabilities, who uses them, what are the rules, what are the acceptance criteria |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How it is built — modules, dependencies, patterns |
| [`SCALING.md`](SCALING.md) | How it holds up — bottlenecks, phased plan to 2000 vehicles |

Requirements are numbered (`FR-xxx-nn`) and carry acceptance criteria so they can be traced to tests. Behaviour described here was read from the code, not inherited from prior documentation; where the code and the existing documentation disagree, the code wins and the discrepancy is recorded in **§10 Defect & Gap Register**.

**Reader's guide**

| You are a… | Read |
| --- | --- |
| Exec / stakeholder | §1 Purpose · §2 Actors · §3 Functional decomposition · §10 Gaps |
| Product manager | §1–§4 · §5 Functional requirements · §10 Gaps · §11 Roadmap |
| System designer / architect | All, especially §5 · §6 Data · §7 Interfaces · §8 NFRs |
| QA / verification | §5 acceptance criteria · §9 Verification & traceability |
| Compliance / audit | §5.7 Proof of Play · §7.3 Reports · §10 (integrity risks) |

---

## 1. Purpose, scope and business objective

### 1.1 The problem

A transit vehicle must tell passengers where it is and what happens next — **audibly** (saloon speakers) and **visually** (interior scrolling sign, exterior destination signs) — in the passenger's language, automatically, without the driver touching anything, and identically on every vehicle in the fleet. Under accessibility legislation (ADA and equivalents) the operator must also be able to **prove**, after the fact, that those announcements actually happened.

Historically this content is baked into vehicle firmware: changing a phrase means a software release and a fleet-wide reflash.

### 1.2 What this system does

**In one sentence:** it lets a non-programmer author what the fleet says, then executes that authored content live against each vehicle's real-time trip feed — synthesising speech, rendering sign graphics, dispatching both back to the vehicle, and recording durable evidence of what the passenger actually heard.

### 1.3 Business objectives

| # | Objective | How the system delivers it | Measured by |
| --- | --- | --- | --- |
| BO-1 | **Announcements without driver action** | Trigger engine derives events from the live trip feed | Announcements dispatched per journey with zero driver input |
| BO-2 | **Content changes without a software release** | Authoring app publishes to the engine; engine hot-swaps in ~1 s | Time from edit to fleet-live; zero redeploys |
| BO-3 | **Accessibility compliance, provable** | Proof-of-play trail correlates dispatch with vehicle-confirmed playback; certified coverage report | Coverage % (confirmed ÷ correlatable dispatches) |
| BO-4 | **Fleet-scale at controlled cost** | Content-addressed TTS cache: a phrase is synthesised once for the entire fleet | TTS cost scales with *unique phrases*, not vehicles |
| BO-5 | **Multi-operator (multi-tenant) from one platform** | Tenant-scoped projects, config, and audit trail | Tenants share no data; one engine process per tenant |

### 1.4 Scope

**In scope:** authoring; publication; trigger evaluation; speech synthesis; interior and exterior sign rendering; dispatch and priority arbitration; onboard playback; proof-of-play capture and reporting; fleet monitoring.

**Out of scope (deliberately):** vehicle hardware and wiring; the upstream trip-data system (PIS-PT is consumed, not produced); driver-facing UI; passenger mobile apps; ticketing.

---

## 2. Actors and stakeholders

| Actor | Type | Goal | Primary surface |
| --- | --- | --- | --- |
| **Announcement Author** | Human (transit operator staff, non-technical) | Design what the fleet says and when | CreatorStudio Creator (`/creator/`) |
| **Fleet Operator / Dispatcher** | Human | Watch the live fleet; scope which vehicles are served; inject test signals | Engine Dashboard (`/monitor/`) |
| **Compliance Officer / Auditor** | Human | Prove announcements were played; certify coverage; export evidence | Proof of Play + `/proof/report` |
| **Field Technician** | Human | Commission a vehicle; spot-check audio on a phone | Browser player (`/player`) |
| **Passenger** | Human (indirect beneficiary) | Hear and see where the vehicle is going | Vehicle speakers + LED signs |
| **Trip Data Feed (PIS-PT)** | External system | Publishes live journey state per vehicle | MQTT `{tenant}/{vehicleId}/pis/0/#` |
| **TTS Supplier** | External system | Synthesise speech | Azure Speech (active), Acapela, ElevenLabs, Mock |
| **Onboard Unit** | Machine | Play the clip; acknowledge completion | Go / Python / browser player |
| **Sign Controller** | Machine | Drive the physical LED signs over RS-485 — vendor-independent | Mobitec FF frames · Hanover HCPS/SuperX frames |
| **Configuration Store** | External system | Hold projects, published config, version history, durable audit | Azure PostgreSQL (via the portal API) + Azure Blob Storage |

---

## 3. Functional decomposition

The system is eight functional blocks across three planes. Each block is independently testable and communicates only over the published interfaces (§7).

```mermaid
flowchart TB
  subgraph author["AUTHORING PLANE — design time"]
    FB1["FB-1 Content Authoring<br/>playlists · voices · lexicon"]
    FB2["FB-2 Rule Authoring<br/>triggers · conditions · geofences"]
    FB3["FB-3 Config Lifecycle<br/>projects · publish · version · rollback"]
  end
  subgraph run["EXECUTION PLANE — run time"]
    FB4["FB-4 Situation Awareness<br/>ingest · aggregate · derive state"]
    FB5["FB-5 Decision<br/>trigger evaluation · gates · priority"]
    FB6["FB-6 Realisation<br/>TTS · cache · LED · FF encode"]
    FB7["FB-7 Dispatch & Playback<br/>schedule · publish · play · ack"]
  end
  subgraph assure["ASSURANCE PLANE — after the fact"]
    FB8["FB-8 Evidence & Operations<br/>proof of play · reports · health"]
  end
  FB1 --> FB3
  FB2 --> FB3
  FB3 -- "published config" --> FB5
  FB4 --> FB5 --> FB6 --> FB7
  FB7 -- "played ack" --> FB8
  FB5 & FB6 & FB7 --> FB8
```

| Block | Responsibility | Realised by |
| --- | --- | --- |
| **FB-1 Content Authoring** | What is said: playlists, elements, voices, per-element overrides, lexicon, pre-recorded clips | `web/creator/` playlist editor, voices, settings |
| **FB-2 Rule Authoring** | When it is said: 33 built-in triggers, fact-based custom triggers, geofence library | `web/creator/` triggers, custom triggers, geofences |
| **FB-3 Config Lifecycle** | Projects, validation, publish, version history, rollback, tenant scoping | Creator services + `publish-engine-config` / `manage-project` edge functions |
| **FB-4 Situation Awareness** | Consume PIS-PT; aggregate multi-topic retained state into one journey state per vehicle | `src/pis/ptContext.ts` |
| **FB-5 Decision** | Edge-detect events; evaluate conditions, prerequisite gates, custom expressions; resolve priority | `src/pis/ptEngine.ts`, `src/engine/customTriggers.ts`, `src/pipeline.ts` |
| **FB-6 Realisation** | Resolve variables; synthesise/cache audio; rasterise announcement signs **and** MatrixRenderer layout templates; encode mono/RGB FF **and Hanover HCPS** frames (vendor-independent sign output) | `src/engine/playlistRenderer.ts`, `tts/*`, `ledRender.ts`, `exteriorSign.ts`, `matrix/*`, `ff/`, `hanover/` |
| **FB-7 Dispatch & Playback** | Per-vehicle scheduling (play/interrupt/queue/drop); publish ADT + display; onboard playback + ack | `src/engine/scheduler.ts`, `clients/go`, `clients/python`, `public/player.html` |
| **FB-8 Evidence & Operations** | Proof-of-play capture, coverage certification, fleet monitoring, served-set control | `src/engine/history.ts`, `proofReport.ts`, `proofSink.ts`, `web/` dashboard |

---

## 4. Operational concept — primary use cases

### UC-1 — Author and publish an announcement (FB-1 → FB-3)
An author selects a project, creates a playlist ("Next stop {nextStop}"), picks a voice, assigns it to the `approaching-stop` trigger, previews it through the real TTS supplier, resolves any lint findings, and clicks **Publish**. Within ~1 second every vehicle on that fleet begins using the new wording. **No redeploy, no vehicle touch.**

### UC-2 — Announce a stop (FB-4 → FB-7)
The vehicle's feed reports progress to the next stop. The engine detects the transition, confirms the trigger's conditions and prerequisite gates hold, renders the playlist (cache hit ≈ 2 ms), publishes an ADT audio payload plus an interior LED bitmap, the onboard player plays it, and — when the clip *finishes* — acknowledges playback. The record becomes evidence.

### UC-3 — Interrupt for an emergency (FB-5, FB-7)
An alarm fires (priority 11) while an ambient announcement (priority 5) is mid-render. The scheduler interrupts: it aborts the superseded render so its audio is never published, and plays the alarm. Neither talks over the other.

### UC-4 — Prove compliance for an audit period (FB-8)
A compliance officer scopes a route and date range and generates a certified coverage report: dispatched vs confirmed-played, per route/vehicle/day, an itemised exception list, an operator sign-off block, and a SHA-256 integrity hash. With GTFS enabled the report additionally reports stops that were **never attempted** — which dispatch-based coverage alone cannot detect.

### UC-5 — Scope the fleet safely (FB-8 → FB-5)
The engine defaults to `RENDER_SCOPE=selection`: it announces **nothing** until an operator explicitly opts vehicles into the *served set*. This makes "connect to production and accidentally talk to 2000 buses" impossible by default.

### UC-6 — Roll back a bad publish (FB-3)
An author publishes wording that reads badly on air. From **History** they restore an earlier version in one click; it is re-published as a *new* version — the audit trail is append-only and nothing is rewritten.

---

## 5. Functional requirements

Convention: **shall** = mandatory, implemented. Each requirement carries an acceptance criterion (AC). Requirements marked **⚠** have a known deviation recorded in §10.

### 5.1 FR-CFG — Configuration authoring and lifecycle

| ID | Requirement | AC |
| --- | --- | --- |
| FR-CFG-01 | Authoring shall be organised into **Projects** — named, self-contained configurations (playlists, triggers, voices, lexicon, geofences, volume rules). | Create/rename/duplicate/delete/select/import/export a project; deleting the last project is refused. |
| FR-CFG-02 | Exactly **one project shall be published per (tenant, fleet)** at a time; the published row is what the engine reads. | `engine_config` has a unique constraint on `(tenant, fleet)`; the switcher tags the live project. |
| FR-CFG-03 | Publication shall be **blocked** when any *enabled* trigger references a playlist that does not exist. | Publish dialog refuses to open; the offending trigger is listed. Disabled triggers are exempt. |
| FR-CFG-04 | The engine shall **hot-swap** a published config without restart, within ~1 s. | Publish → engine logs a config version bump and applies new wording on the next trigger. |
| FR-CFG-05 | The engine shall read config **remote-first** (Postgres direct or portal API) with an on-disk file as offline fallback, and hot-reload the file on change. | With a remote source configured the file is shadowed; with none the file wins. |
| FR-CFG-06 | Every publish shall append an immutable **version record** (version, config snapshot, note, publisher, source project, timestamp). | `config_versions` row created per publish; version is monotonic per (tenant, fleet). |
| FR-CFG-07 | An author shall be able to **roll back** to any earlier version in one click. | Restore re-publishes the old config **as a new version**; history is never rewritten. |
| FR-CFG-08 | All authoring state shall be **tenant-scoped**; tenants shall share no projects, config, history, or audit data. | Switching tenant loads a disjoint project set; RLS enforces tenant on read; edge functions enforce the verified tenant claim on write. |
| FR-CFG-09 | Config edits shall be **undoable** (≥50 steps), with rapid keystrokes coalesced. | Undo/redo restores prior state; a burst of typing is one undo entry. |
| FR-CFG-10 | The system shall **lint** the config live and surface findings before publish. | Errors: enabled trigger with no playlist; trigger pointing at a deleted playlist. Warnings: playlist with no spoken content; event-specific variable used on a trigger that cannot provide it. |
| FR-CFG-11 | Deleting a referenced entity shall **scrub its references**, never dangle. | Deleting a playlist clears it from every trigger slot and sequence; deleting a geofence clears it from every custom-trigger block. |
| FR-CFG-12 | Config shall be importable/exportable as a **versioned JSON envelope** interoperable with the engine and the legacy app. | `schema: luminator.announcement-config`, `schemaVersion: 1.2.0`; round-trips without loss. |

### 5.2 FR-CNT — Content (what is said)

| ID | Requirement | AC |
| --- | --- | --- |
| FR-CNT-01 | A playlist shall be an ordered list of elements of exactly five types: `static-text`, `dynamic-text`, `pause`, `audio-file`, `audio-ref`. | Each type is addable, reorderable, deletable. |
| FR-CNT-02 | A playlist shall declare **speaker routing**: `interior`, `exterior`, or `both`. | Routing determines ADT speaker levels and whether an interior LED frame is published. |
| FR-CNT-03 | Voice shall be settable **per playlist** and overridable **per element**. | An element with its own voice splits the TTS run and renders under that voice. |
| FR-CNT-04 | Dynamic text shall resolve **30 variables** from live journey state, in the language of the voice speaking them. | `{nextStop}` etc. resolve; `name_Multilanguage` variants are selected per element language (16 locales). |
| FR-CNT-05 | Volume shall be settable per element (0–100) and applied as SSML prosody. | Honoured where the supplier declares `prosodyVolume` (Azure/Google/Polly); a volume change splits the TTS run like a voice change. On plain-text suppliers the renderer **drops the volume predictably** (runs stay merged, a log line names it) and the Creator warns while authoring. |
| FR-CNT-06 | Voice **pitch shall not be authorable**. | Deliberate: a shifted pitch produces audibly broken speech that no log or payload reveals. Carried in the model, ignored by the engine. |
| FR-CNT-07 | A **pronunciation lexicon** shall override how words are spoken, by plain respelling or IPA, optionally restricted to one language. | Applied identically in engine render, playlist preview and simulator (word-boundary, case-insensitive). |
| FR-CNT-08 | The system shall support **multilingual playlists**: one authoring action duplicates the spoken block in a second language and voice, preserving pauses and cadence. | The playlist announces in both languages; variables resolve their per-language name variants. |
| FR-CNT-09 | **Pre-recorded stop-name clips** shall substitute for TTS when a stop-name variable matches a recording; the remainder of the announcement is still synthesised. | Bulk import parses stop name and language from file names; clips may be embedded as data URIs or hosted. |
| FR-CNT-10 | An author shall be able to **audition** a playlist through the engine's *real* TTS supplier before publishing. | Preview renders with realistic sample variable values and the lexicon applied. |
| FR-CNT-11 | The TTS supplier shall be selectable per config, from Azure / ElevenLabs / Acapela / Mock, with live A/B comparison. | Voice catalogues are fetched from each supplier; "Use for engine" is applied to the fleet on publish. |

### 5.3 FR-TRG — Rules (when it is said)

| ID | Requirement | AC |
| --- | --- | --- |
| FR-TRG-01 | The system shall implement a canonical set of **33 built-in trigger types** spanning journey lifecycle (7), stop flow (7), sensors (2), distance/time thresholds (5), spatial/motion (3), and service/passenger info (7)+2 local. | Enumerated in §5.3.1. A bidirectional parity guard fails the build if the engine and the authoring manifest drift. |
| FR-TRG-02 | Triggers shall fire on **state transitions**, not on message arrival, and shall be debounced (default 1500 ms per vehicle+kind). | A repeated identical message produces no second announcement. |
| FR-TRG-03 | A settling window (default 1500 ms) shall suppress firing immediately after a vehicle is first seen or re-seen. | Reconnecting to a fleet with retained topics does not produce a burst of stale announcements. |
| FR-TRG-04 | Threshold triggers shall carry **conditions** with explicit operators and units. | Time (s), distance (m/ft), geofence (radius m + centre), speed (below/above/between, km/h or mph), occupancy (%). Defaults defined in §5.3.2. |
| FR-TRG-05 | Any trigger shall support **universal prerequisite gates**: door open, door closed, stop button pressed, velocity below, velocity above. A trigger is suppressed unless every set gate holds. | Gates are **fail-closed** — a velocity gate with no speed reading blocks the fire. Authored per trigger under **Prerequisites** in the Creator (D-07). |
| FR-TRG-06 | A fire blocked *only* by a prerequisite gate shall be **held and retried**, not discarded, for a bounded window. | Gate-hold retries until it fires, the window expires, or the vehicle moves to the next stop. |
| FR-TRG-07 | GPS-derived triggers (speed, geofence) shall only be evaluated when the **GNSS fix is trusted**, and shall hold the last trusted value across an untrusted gap. | An untrusted gap does not manufacture a spurious rising edge. |
| FR-TRG-08 | The system shall support **custom (fact-based) triggers**: a nested boolean expression (ALL/ANY/NOT) over a catalogue of live facts and named geofences, with a fire mode, cooldown, priority and playlist. | Author builds the expression visually; "Try it" gives a live would-fire verdict. |
| FR-TRG-09 | The custom-trigger fact catalogue shall cover **40 facts** in 7 categories, including engine-clock time and calendar facts, so purely schedule-driven announcements are possible with no vehicle data. | Enumerated in §5.3.3. Operators are type-aware (boolean/number/enum/string). A parity test keeps the catalogue and the engine's `snapshotFacts()` in lockstep. |
| FR-TRG-10 | Custom triggers shall support fire modes **`becomes-true`** (rising edge, once) and **`while-true`** (repeats), throttled by a cooldown. | First fire is never suppressed by cooldown; a disabled or unassigned trigger is skipped and its edge state cleared. |
| FR-TRG-11 | The system shall maintain a **named geofence library** supporting **circles and polygons**, usable by custom triggers. | Circle: centre + radius (20–4000 m). Polygon: ≥3 vertices, point-in-polygon by ray casting. |
| FR-TRG-12 | An author shall be able to **draw and reshape** geofences on a map: drag a corner, insert a corner on an edge, delete a corner, move the whole zone, resize a circle, duplicate, recolour, delete. | Corner deletion is refused below 3 vertices (a degenerate ring would silently match nothing). Edits are staged; Cancel discards, Save commits. |
| FR-TRG-13 | Trigger→playlist resolution shall support **interior/exterior split playlists, sequences and repetitions**. | Engine resolves and concatenates sequences, then repeats. Authored in the trigger row's **Audio** group (D-08); a `triggerJobsParity` test pins the Creator's preview to the engine's `resolveJobs`. |

#### 5.3.1 Built-in trigger set (33)

| Group | Trigger types |
| --- | --- |
| Journey lifecycle (7) | `journey-not-in-traffic`, `journey-not-in-traffic-countdown`, `journey-activated`, `journey-running`, `journey-offroute`, `journey-approaching-last-stop`, `journey-arrived-at-destination` |
| Trip / stop flow (7) | `trip-start`, `approaching-stop`, `arrived-at-stop`, `departing-stop`, `last-stop`, `stop-skipped`, `stop-request` |
| Sensors (2) | `doors-open`, `doors-close` |
| Distance / time thresholds (5) | `distance-to-stop`, `distance-before-stop`, `distance-after-stop`, `time-to-stop`, `time-after-stop` |
| Spatial / motion (3) | `geofence`, `speed`, `exit-side` |
| Service / passenger info (7) | `connection-info`, `situation-message`, `alarm-activation`, `destination-override`, `passenger-load`, `detour`, `bus-type` **⚠ never fires — flagged unavailable in the Creator** |
| Local-only (2) — accepted for import, not MQTT-fired | `stationary-display`, `volume-calibration` |

**MQTT-driven set = 30.** Custom triggers fire as a 31st event kind outside this taxonomy.

#### 5.3.2 Condition catalogue

| Condition | Operator | Unit | Default |
| --- | --- | --- | --- |
| `timeToStopSeconds` | ≤ | seconds | 60 |
| `timeAfterStopSeconds` | ≥ since departure | seconds | 30 |
| `distanceToStopMeters` / `distanceBeforeStopMeters` | ≤ | m or ft (**threshold only** — `{distanceToStop}` always speaks metres) | 200 |
| `distanceAfterStopMeters` | ≥ travelled from departure point | m or ft | 200 |
| `geofenceCondition` | `within` / `outside` / `entering` / `leaving` **⚠ collapse to 2 behaviours** | — | `within` |
| `geofenceRadius` + `geofenceCenter` | ≤ haversine | metres | 100 |
| `speedCondition` | `below` (<) / `above` (>) / `between` (inclusive) | km/h or mph | — (unset ⇒ never fires) |
| `occupancyThreshold` | ≥ (worst car) | percent | 80 |
| `exitSides` | ∈ authored sides (feed `Both` also satisfies a Left- or Right-only selection) | — | unset ⇒ every real side |

**Prerequisite gates** (authored per trigger under **Prerequisites**; universal, and they **fail closed** — a gate whose signal is absent blocks the announcement): `requiresDoorOpen`, `requiresDoorClosed`, `requiresStopButtonPressed`, `requiresVelocityBelow`, `requiresVelocityAbove` (both in `speedUnit`). The engine also declares `requiresDirection`, **not enforced — no forward/reverse signal in the feed** and deliberately not authorable (D-04).

#### 5.3.3 Custom-trigger fact catalogue (40)

| Category | Facts |
| --- | --- |
| Time & calendar (7) | `hour`, `minute`, `weekday`, `isWeekend`, `month`, `dayOfMonth`, `year` — engine clock in the project's IANA timezone |
| Journey (9) | `journeyState` (7 states), `offRoute`, `lineNumber`, `lineCode`, `destinationNumber` (the pre-programmed code the driver keyed in), `countdownSeconds`, `countdownMinutes`, `tripId`, `extraText` |
| Stops (10) | `nextStopName`, `nextStopSeq`, `distanceToNext`, `timeToStopSec`, `distanceFromPrev`, `finalDestination`, `stopInfoType`, `viaPassed`, `mainStops`, `nextStopBoardingAllowed` |
| Doors & buttons (2) | `doorOpen`, `stopPressed` |
| Movement (2) | `speedKmh`, `gpsTrusted` |
| Energy (2) | `stateOfCharge` (traction-battery %, from `vehicle/energy`), `charging` — electric-fleet triggers; a feed without energy data never satisfies either |
| Passengers & service (8) | `occupancyPercent`, `connectionCount`, `connectionDelayed`, `connectionCancelled`, `maxConnectionDelaySec`, `situationCount`, `alarmActive`, `exitSide` |
| Geofence (per zone) | `inside` / `outside` any named zone in the library (`lat`/`lon` are geofence-only pseudo-facts, not offered as plain conditions) |

The same catalogue feeds the **LED templates**: a cycle's visual condition compiles to an expression
over `globalState.<factId>`, so a sign rotation and an announcement are gated by the same facts.

Operators by type — boolean: `isTrue`, `isFalse`, `changed` · number: `lt`, `lte`, `gt`, `gte`, `eq`, `ne`, `changed` · enum: `eq`, `ne`, `changed` · string: `eq`, `ne`, `contains`, `isEmpty`, `isNotEmpty`, `changed`.

### 5.4 FR-REN — Realisation (audio and signage)

| ID | Requirement | AC |
| --- | --- | --- |
| FR-REN-01 | The engine shall render a playlist to **one audio clip plus a transcript**, merging consecutive elements that share a voice into a single TTS request. | A voice or volume change splits the run; identical runs still hit the shared cache. |
| FR-REN-02 | Identical phrases shall be **synthesised once for the entire fleet** (content-addressed cache: memory LRU → filesystem → Azure Blob Storage). | Cache key = hash of the exact synthesis inputs. Second vehicle speaking the same phrase costs zero TTS calls. |
| FR-REN-03 | Concurrent identical synth requests shall be **collapsed into one** (single-flight). | A fleet-wide cold miss on the same phrase triggers one supplier call, not hundreds. Surfaced as `synthCoalesced`. |
| FR-REN-04 | The engine shall **predictively pre-render** upcoming stops so live announcements hit a warm cache. | Volatile playlists (containing `{currentTime}`, `{distanceToStop}`, …) are correctly skipped. |
| FR-REN-05 | TTS shall be **resilient**: retry with backoff, circuit-breaker, and fallback audio. Auth errors (401/403) skip retries. | A supplier outage never drops an announcement; the LED sign still renders from text. Breaker state is on Health. |
| FR-REN-06 | The interior sign shall be rendered as a **16×144 amber 1-bpp bitmap**, centred when it fits and flagged `scroll` when wider. | Published with the same `traceId` as the audio, for `outputType` `interior` or `both`. |
| FR-REN-07 | Exterior signs shall be rendered for **front (192px) / side (160px) / rear (48px)**, 24px tall: a fixed route number that never scrolls, plus a destination that **shrinks to fit** and scrolls only as a last resort. | Published **retained**, only when route/destination changes. |
| FR-REN-08 | The same bitmaps shall also be emitted as **raw Mobitec FF frames** (board 1463-L, graphic font `w`) on separate topics for an RS-485 gateway. | Frame = `0xFF · address · data · checksum · 0xFF`; decode round-trip is tested. Interior addr 10; exterior front/side/rear 1/2/3. |
| FR-REN-09 | The browser shall rasterise signs with the **same font the engine ships**, so previews and replays match the hardware exactly. | Font served at `/led-font.json`; dashboard and engine produce identical bitmaps. |
| FR-REN-10 | An announcement with unresolved required variables shall be **droppable** rather than spoken with a gap. | Governed by `SKIP_INCOMPLETE_ANNOUNCEMENTS`. Nine variables are classified *optional* and never block. |
| FR-REN-11 | Sign output shall be **vendor-independent**: the same bitmaps shall also be emitted as **Hanover HCPS/SuperX graphic frames** on parallel `…/hanover` topics (in parallel with FF when both are enabled). | Frame = `STX · '0' · addr(hex) · SuperX {\pic} · ETX · 2-char checksum`; checksum + raster packing verified byte-exact against the vendor docs in `Hanover/`; decode round-trip tested. On by default (`DISPLAY_HANOVER_ENABLED`). |

### 5.4.1 FR-LED — Matrix LED templates (Luminator MatrixRenderer)

Authors design destination and interior signs as first-class content — the same **Display → Cycles → Layout → Elements** contract as `LuminatorSuite.Unit.MatrixRenderer.Api` — and bind them to vehicle faces and triggers. Audio playlists and LED templates are parallel outputs of the same publish / trigger pipeline.

| ID | Requirement | AC |
| --- | --- | --- |
| FR-LED-01 | The Creator shall author **LED layouts** as width×height pixel canvases of Text / Image / Rectangle elements with declarative auto-format (overflow, alignment, scroll, alternation, blink, colours, image scaling). | Layout editor supports create / duplicate / delete; live thumbnail + sample-state preview. |
| FR-LED-02 | A **Display** shall hold an ordered cycle tree; each cycle may carry a visual trigger (`when` / `caseWhen`) that compiles to `enabledExpression`, plus timed rotations pointing at a `layoutId`. | Display editor preview shows the same "which layout wins" result the engine resolver produces. |
| FR-LED-03 | Authors shall maintain a **vehicle roster** (Ultima models, resolution, position, FF address, colour mode) and bind each face to a Template display or Announcement-text source. | "Assign default templates" / "Set up standard vehicle" produce a working in-service / not-in-service setup. |
| FR-LED-04 | **Symbol mappings** and **colour rules** shall restyle a face from live field values (e.g. line code → pictogram; service state → colours). | Applied identically in Creator preview, dashboard live panel and engine render. |
| FR-LED-05 | Built-in and custom triggers shall optionally bind a **layout per vehicle face** for as long as the trigger fires; otherwise the bound Display cycles select the layout from `globalState`. | Override order: firing trigger face binding → else Display cycles. NIS can blank unbound faces. |
| FR-LED-06 | The engine shall rasterise layouts with real **Luminator FNT fonts** (font ladder) and emit **mono or RGB Mobitec FF** frames (`encodeFf` / `encodeFfRgb`, protocol 03090). | Round-trip / parity tests cover FNT parse, layout render and FF RGB encode. Gated by `display.templateMode`. |
| FR-LED-07 | Creator models (`web/…/led-template.ts`) and engine models (`src/domain/ledTemplate.ts`) shall stay in lockstep; browser and engine layout renderers shall produce matching frames. | Guarded by `test/ledTemplate.test.ts` and matrix/layout parity tests. |
| FR-LED-08 | The Engine dashboard's **Displays** drawer (in the Monitor cockpit) and the Creator **Simulator** shall preview the published templates against live / simulated journey state. | Same cycle + trigger-override pipeline as the engine; empty config links to Creator. The former standalone `/signs` page is merged into the cockpit and redirects there. |
| FR-LED-09 | Each rostered sign shall declare its **wire protocol** (`ff` default, or `hanover`), and the publish shall emit the matching raw frame per sign — mixed-vendor rosters supported in one build. | `protocol: 'hanover'` signs publish `<signs>/{slug}/hanover` + the `…/signs/hanover` aggregate instead of FF; protocol changes alone re-publish (signature covers it). Covered by `matrixParity.test.ts`. |
| FR-LED-10 | Raw wire frames shall also publish on **address-specific topics** on every raw-frame path — `<signs>/ff/{address}`, `<signs>/hanover/{address}`, plus the fixed-face `…/display/ff/{address}`, `…/display/exterior/ff/{address}` and Hanover twins — so a sign controller subscribes to its own bus address instead of filtering the aggregate. | Signs/faces sharing an address concatenate on its topic; on the template path a re-addressed or removed sign has its old retained address topic cleared. Covered by `matrixParity.test.ts` + `pipeline.test.ts`. |

### 5.5 FR-DSP — Dispatch and arbitration

| ID | Requirement | AC |
| --- | --- | --- |
| FR-DSP-01 | **Exactly one announcement shall play per vehicle at a time.** | Enforced by a per-vehicle scheduler slot. Zero cross-talk verified at fleet scale. |
| FR-DSP-02 | Priority shall be authorable (1–11) in both a flat and a nested shape, with per-type defaults when unset. | Defaults: alarm 11 · detour/situation 10 · stop-request/off-route 9 · … · volume-calibration 1. |
| FR-DSP-03 | The scheduler shall resolve every offer to exactly one of **play · interrupt · queue · drop**. | play if idle; interrupt if strictly higher priority **and** `interruptLower`; queue if `queueIfBlocked`; else drop. |
| FR-DSP-04 | An interrupt shall **abort the superseded render** so its audio is never published on top of the higher-priority clip. | The aborted render skips its publish; `interrupted` is counted on Health. |
| FR-DSP-05 | The queue shall be **priority-ordered** (ties by arrival), bounded, and shall shed the **lowest-priority tail** on overflow. | Default depth 8. Expired entries are purged before each decision. |
| FR-DSP-06 | Audio shall be published as an **ADT 4.x** payload (base64 MP3/OPUS, integer speaker levels 0–100). | Conforms to the transHub AsyncAPI spec; validated by contract tests. |
| FR-DSP-07 | The engine shall render **only for vehicles in the served set** by default (`RENDER_SCOPE=selection`); an empty set means silence. | Production-safe: connecting to a live fleet announces nothing until an operator opts vehicles in. `all` is load-test only and raises a visible warning in the UI. |

### 5.6 FR-PLY — Onboard playback

| ID | Requirement | AC |
| --- | --- | --- |
| FR-PLY-01 | Three interchangeable players (**Go**, **Python**, **browser**) shall implement one identical contract, so a fleet can mix them. | Subscribe `{tenant}/{vehicleId}/pis/0/tts`; ack `engine/played` with `{traceId, tenant, vehicleId, dispatchedAt, playedAt, status}`. |
| FR-PLY-02 | A player shall acknowledge **when the clip finishes**, not when it arrives. | This is what distinguishes proof-of-**play** from proof-of-dispatch. `status` ∈ `played` \| `failed`. |
| FR-PLY-03 | Clips shall be **queued and played strictly one at a time**; announcements shall never overlap. | Go: single-goroutine drain of a bounded channel. Python: bounded queue + one worker. Browser: serialised promise chain. |
| FR-PLY-04 | A player shall never block its network loop; an over-full queue shall **drop with a log line**, and a wedged clip shall time out (120 s). | A stuck decoder cannot silence the vehicle permanently. |
| FR-PLY-05 | Playback volume shall be taken from `speakers.INTERNAL` (0–100), scaled by an operator-set device volume. | **⚠ No player drives `speakers.EXTERNAL` (§10).** |
| FR-PLY-06 | Headless players shall ship as **multi-arch containers** (amd64 + arm64) needing only outbound MQTT and `--device /dev/snd`. | Runs behind vehicle NAT unchanged; reconnects on its own. |
| FR-PLY-07 | A zero-install **browser player** shall be deep-linkable and QR-handoff-able for demos and field spot-checks. | `/player?tenant=…&vehicle=…`; requests a screen wake-lock; one tap to satisfy browser autoplay policy. |

### 5.7 FR-AUD — Evidence and compliance

| ID | Requirement | AC |
| --- | --- | --- |
| FR-AUD-01 | The system shall keep an **append-only** audit trail of three record types on one timeline: `audio` (dispatched), `exterior` (sign changed), `played` (vehicle-confirmed). | Local rolling JSON-lines, bounded and auto-trimmed; writes are serialised and never block the render pipeline. |
| FR-AUD-02 | An `audio` record shall carry vehicle, tenant, GPS, time, trigger, stop, route, destination, journey ref, transcript, **the lexicon-adjusted spoken text**, language, voice, routing, volume, timing, cache stats, and whether the interior sign displayed it. | Full field list in §6.3. |
| FR-AUD-03 | A `played` record shall be **correlated to its dispatch by `traceId`** and shall carry the original dispatch time, so end-to-end latency is measurable. | Enables the dispatched-vs-played distinction. |
| FR-AUD-04 | The system shall compute **coverage** = confirmed ÷ *correlatable* dispatches. Records with no `traceId` are excluded from the rate, not counted as failures. A later `played` supersedes an earlier `failed` (a retry that succeeded). | Per-announcement outcome ∈ `confirmed` \| `failed` \| `unconfirmed` \| `uncorrelated`. |
| FR-AUD-05 | The audit trail shall be **queryable and aggregatable** by tenant, vehicle, route, destination, journey, trigger, surface, free text and time range; and groupable by journey/route/destination/date/hour/vehicle. | Server-side aggregation over the full filtered set, not just the page. |
| FR-AUD-06 | The system shall produce a **certified coverage report** in JSON, CSV and print-ready HTML. | Includes coverage headline, per-route/vehicle/day breakdowns, an itemised exceptions table, an explicit basis disclaimer, and an operator sign-off/attestation block. |
| FR-AUD-07 | The report shall carry an **integrity hash** (SHA-256 over the canonical outcome tuples) so a stored report can be proven unaltered. | Regenerating the report over the same data reproduces the hash. |
| FR-AUD-08 | With a GTFS feed, the report shall additionally certify **schedule-based expected coverage** — stops that were never announced at all. | Dispatch-based coverage cannot detect a never-attempted stop; this closes that hole. |
| FR-AUD-09 | The audit trail shall be **durably persistable** beyond the engine host, per tenant. | Opt-in Postgres sink (`PROOF_DB_ENABLED`), batched, best-effort: a sink failure is logged and never breaks the pipeline; the local file remains the record. |
| FR-AUD-10 | Replay shall re-show the interior and exterior signs and re-voice the transcript, and shall be **labelled a reconstruction**. | The UI states plainly: "Replay re-voices the transcript via the browser — the record is the proof." |

### 5.8 FR-OPS — Operations and monitoring

| ID | Requirement | AC |
| --- | --- | --- |
| FR-OPS-01 | An operator shall control the **served set** (which vehicles the engine renders for) from the dashboard; the control shall be **retained** and shall survive an engine restart. | `engine/control/select`; bidirectional — the UI re-syncs from whoever set it. |
| FR-OPS-02 | The engine shall publish **health telemetry** at a fixed interval: throughput, event rate, event-loop lag, RSS, cache hit rate, in-flight/queued/dropped/interrupted, dedup, pre-render, and TTS breaker state. | Full field list in §7.1. Per-interval counters reset each beat. |
| FR-OPS-03 | The engine shall publish **per-announcement metrics** broken into resolve / render / TTS / publish, with GPS. | Drives the latency breakdown and the event map. |
| FR-OPS-04 | The system shall raise **operational alerts**: TTS breaker open, announcements dropped, vehicles silent >5 min. | Surfaced as banners in the dashboard header. |
| FR-OPS-05 | A retained **fleet directory** shall let the vehicle picker populate without subscribing to the whole fleet. | One topic (`engine/fleet`) carries route, destination, current stop, stops left, phase per vehicle. |
| FR-OPS-06 | An operator shall be able to **inject test signals** into a vehicle (stop request, doors, off-route, exit side, crowding, alert, alarm, destination override) to exercise triggers without waiting for real conditions. | Publishes real PIS topics; the stop-request is momentary and auto-releases. |
| FR-OPS-07 | An author shall be able to **simulate a real GTFS journey** and see, per event, exactly what would be spoken — including custom triggers edge-detected the same way the engine does. | Timeline shows "Trigger not enabled" / "no playlist assigned" / the resolved spoken text; each event is auditionable. |
| FR-OPS-08 | The upstream bridge shall be **lean**: follow the served set, keep fleet discovery minimal, tenant-scope discovery, and drop byte-identical re-publishes. | Publishes its own retained health (forwarded/s, deduped/s, bridged vehicles). |

---

## 6. Data model — key entities

### 6.1 Authoring entities

```mermaid
erDiagram
  PROJECT ||--|| ANNOUNCEMENT_CONFIG : contains
  ANNOUNCEMENT_CONFIG ||--o{ PLAYLIST : has
  ANNOUNCEMENT_CONFIG ||--o{ TRIGGER : has
  ANNOUNCEMENT_CONFIG ||--o{ CUSTOM_TRIGGER : has
  ANNOUNCEMENT_CONFIG ||--o{ GEOFENCE : has
  ANNOUNCEMENT_CONFIG ||--o{ LEXICON_ENTRY : has
  ANNOUNCEMENT_CONFIG ||--o{ VOLUME_RULE : has
  PLAYLIST ||--o{ ELEMENT : contains
  TRIGGER }o--|| PLAYLIST : "fires"
  CUSTOM_TRIGGER }o--|| PLAYLIST : "fires"
  CUSTOM_TRIGGER }o--o{ GEOFENCE : "tests"
  PROJECT ||--o{ CONFIG_VERSION : "published as"
```

| Entity | Key attributes |
| --- | --- |
| **Project** | id, tenant, fleet, name, description, config, timestamps |
| **Playlist** | id, name, language, outputType (`interior`\|`exterior`\|`both`), voiceSettings, elements[] |
| **Element** | id, type (5 literals), content, volume (0–100), voiceSettings?, pauseDuration?, audioUrl? |
| **Trigger** | type (1 of 33), enabled, playlistId (+ interior/exterior/sequence/repetition slots), condition, priority, interruptLower, queueIfBlocked |
| **Custom Trigger** | id, name, enabled, `when` (nested ALL/ANY/NOT expression), fireMode, cooldownSec, playlistId, priority |
| **Geofence** | id, name, colour, shape: `{kind: circle, lat, lng, radius}` \| `{kind: polygon, points: [lat,lng][]}` |
| **Lexicon Entry** | word, pronouncedAs, ipa?, language? |
| **Volume Rule** | type (`combined` — ANDed timeRange / weekdays / routes / stops / geofenceIds conditions; legacy `time-of-day`\|`route`\|`stop` still honoured), volume, enabled |

### 6.2 Runtime entities

| Entity | Meaning |
| --- | --- |
| **VehicleContext** | Per-vehicle aggregation of all retained PIS-PT topics; idle-TTL evicted |
| **JourneyState** | Normalised view derived from the context — the input to trigger evaluation |
| **PisTriggerEvent** | An edge-detected event (31 kinds incl. `custom`) with its payload |
| **Announcement job** | A resolved (trigger → playlist[s] → repetitions) unit of work with a priority |

### 6.3 Evidence entities (`HistoryRecord` / `proof_of_play`)

`at` · `type` (`audio`\|`exterior`\|`played`) · `traceId` · `tenant` · `vehicleId` · `vehicleKey` · `lat` · `lon` · `kind` · `triggerType` · `stopName` · `transcript` · `spoken` · `language` · `voice` · `outputType` · `volume` · `playlists[]` · `reps` · `bytes` · `totalMs` · `ttsMs` · `cacheHits` · `cacheMisses` · `unresolved` · `displayed` · `displayScroll` · `route` · `destination` · `journeyRef` · `faces[]` (exterior) · `playStatus` (played) · `dispatchedAt` (played)

### 6.4 Persistence

| Store | Holds | Written by | Read by |
| --- | --- | --- | --- |
| `projects` | Authoring source of truth | `manage-project` (service role) | Creator (anon, RLS, tenant-aware) |
| `engine_config` | The **published output**, one row per (tenant, fleet) | `publish-engine-config` (service role) | **Engine** (anon + Realtime) |
| `config_versions` | Append-only publish history | Portal `/api/publish` (one transaction) | Creator History screen |
| `proof_of_play` | Durable audit trail | Engine sink (batched `pg` INSERT) | Dashboard / Creator (portal API, tenant-aware) |
| `config/announcement-config.json` | Offline fallback config | Operator / import | Engine, only when no remote config source is set |
| `.data/history.jsonl` | Local rolling audit log | Engine | `/history`, `/proof/report` |

> **Direction matters.** `projects` is the **input**, `engine_config` the **output**, the file the **fallback**. Writing to `engine_config` does not change what the author sees — it is overwritten by the next publish. **To change what an author sees, write the project.**

---

## 7. External interfaces

### 7.1 MQTT — the primary integration contract

| Topic | Dir | Payload |
| --- | --- | --- |
| `{tenant}/{vehicleId}/pis/0/<sub>` | in | PIS-PT trip data (multi-topic, retained) |
| `{tenant}/{vehicleId}/pis/0/tts` | out | **ADT 4.x** audio — base64 clip + integer speaker levels + transcript |
| `{tenant}/{vehicleId}/pis/0/display` | out | Interior sign, 1-bpp bitmap JSON |
| `{tenant}/{vehicleId}/pis/0/display/exterior` | out | Front/side/rear signs (retained) |
| `…/display/ff` · `…/display/exterior/ff` | out | Raw Mobitec FF frames (RS-485 gateway) |
| `…/display/hanover` · `…/display/exterior/hanover` | out | Raw Hanover HCPS/SuperX frames — same bitmaps, Hanover signs (RS-485 gateway) |
| `…/display/signs` · `…/display/signs/ff` | out | **Matrix template signs** — one frame per rostered vehicle face (JSON + mono/RGB FF), deduped by signature; gated by `DISPLAY_TEMPLATE_MODE`. Per-address raw frames on `…/signs/ff/{address}` · `…/signs/hanover/{address}` |
| `…/pis/0/list/destinations` | out | The active **pre-programmed destination list** (retained), off unless `DESTINATION_LIST_PUBLISH=true` |
| `engine/played` | in | **Played ack** — `{traceId, tenant, vehicleId, dispatchedAt, playedAt, status}` |
| `engine/metrics` | out | Per-announcement timing + GPS |
| `engine/health` | out | `at, renderScope, served, vehicles, msgPerSec, eventsPerSec, inFlight, queued, dropped, interrupted, memRssMb, loopLagMs, cacheHitRate, cacheSize, synthCoalesced, prerendered, prerenderQ, ttsBreakerOpen, ttsFallbacks, ttsRetries` |
| `engine/fleet` | out | Fleet directory (retained) |
| `engine/bridge` | out | Bridge health (retained) |
| `engine/control/select` | ctl | **Served set** (retained) — `{vehicleKeys: []}` |
| `engine/control/tenant` | ctl | Bridge tenant scope (retained) |

**Inbound PIS-PT sub-topics consumed:** `journey`, `destination`, `destination/override`, `list/stops`, `linkprogress`, `stopinfo`, `journeystate`, `sensors/door`, `sensors/stop_button`, `vehicle/gnss_location`, `vehicle/exit_sides`, `connections`, `passenger_load`, `alarm_activation`, `shape`.

### 7.2 HTTP — portal server (`:8080`)

| Route | Purpose |
| --- | --- |
| `/` · `/monitor/*` · `/creator/*` | Portal menu, engine dashboard, authoring app (one origin, no CORS) |
| `GET`/`POST` `/config` | Live config (remote-first); POST imports a config export |
| `GET /history` | Audit query — filters, `groupBy`, `format=csv` |
| `GET /proof/report` | **Certified coverage report** — `format=json\|csv\|html`, `expected=1` for GTFS coverage |
| `GET /led-font.json` | Shared LED font (browser rasterises identically to the engine) |
| `GET /tts-providers` · `/tts-voices` · `POST /tts-preview` | Supplier catalogue and A/B audition through the engine's real clients |
| `POST /gtfs/import` · `GET /gtfs/*` | GTFS feed upload and introspection |
| `GET /player` · `/player/v/<tenant>/<vehicle>` · `/player/qr.svg` · `/player/hosts` | Browser vehicle player and QR handoff (the QR carries the ids in the path, not a query string) |
| `GET /docs` · `/documentation.html` · `/documentation.md` · `/docs/src/*` | **Documentation Center** — the handbook assembled live from the repo's Markdown, its raw sources, and the decks |
| `GET /architecture.md` | Architecture doc (raw Markdown) |

Mutating or cost-bearing routes (`POST /config`, `/tts-preview`, `/gtfs/import`, `/player/ack`) require
`Authorization: Bearer $DASHBOARD_TOKEN` when that variable is set (constant-time compare); reads are open.

### 7.3 External services

| Service | Protocol | Auth |
| --- | --- | --- |
| MQTT broker | MQTT 3.1.1 (`mqtt://` 1883, `ws://` 9001) | user/pass (optional) **⚠ see §10** |
| Azure AI Speech | REST + SSML | subscription key |
| Azure PostgreSQL (config, versions, proof) | `pg` SQL + `LISTEN`/`NOTIFY`; portal `/api/*` for browsers & vehicles | `DATABASE_URL` (`sslmode=require`); optional `CONFIG_API_TOKEN` |
| Azure Blob Storage | `audio-cache` container (MP3 cache), objects `tts-cache/<sha256>.mp3` | container SAS URL (`CACHE_BLOB_SAS_URL`) |
| Acapela / ElevenLabs | REST | account key/token |

---

## 8. Non-functional requirements

Values marked **[M]** are measured, not estimated.

| ID | Requirement | Target | Evidence |
| --- | --- | --- | --- |
| NFR-PERF-01 | Announcement latency, cache hit (the dominant fleet-wide case) | **≈2 ms** total, `ttsMs=0` | **[M]** live run, Azure Speech |
| NFR-PERF-02 | Announcement latency, cold synth of a unique phrase | **≈1.0 s**, once per unique phrase | **[M]** live run |
| NFR-PERF-03 | Config publish → fleet live | **≈1 s** | `pg_notify`/LISTEN hot-swap |
| NFR-SCAL-01 | Single hardened engine process shall serve **2000 vehicles** on a 2-vCPU / 4 GB host | 2000 vehicles: **29 ms** p50 loop lag, 250 MB RSS, **zero drops**, 100% cache hit | **[M]** load ladder 50→4000, 2026-07-06 |
| NFR-SCAL-02 | Known single-process ceiling | **~3000–3500** on that hardware (4000 saturates the loop at ~870 ms lag) | **[M]** same ladder |
| NFR-SCAL-03 | Multi-tenant scaling model | **One engine process per tenant** (bridge pre-scopes; engine reads only its `(tenant, fleet)` row) | Realised model for a ~5k-vehicle multi-tenant broker |
| NFR-COST-01 | TTS cost shall scale with **unique phrases, not vehicle count** | Fleet growth adds ~zero TTS cost for repeated phrases | Content-addressed cache + single-flight dedup |
| NFR-AVAIL-01 | A TTS supplier outage shall not drop an announcement | Retry → breaker → fallback clip; signs still render from text | `tts/resilient.ts` |
| NFR-AVAIL-02 | The engine shall never crash on a missing broker; players shall reconnect unattended | Daemon semantics; `restart: unless-stopped` | — |
| NFR-RES-01 | Memory shall stay flat under sustained fleet load | LRU audio cache + on-disk file cap + idle context eviction | **[M]** RSS stable across the ladder |
| NFR-SAFE-01 | Connecting to a live fleet shall announce **nothing** until explicitly opted in | `RENDER_SCOPE=selection` default; empty served set = silence; an invalid value **fails at startup** | A typo cannot silently change who gets served |
| NFR-SEC-01 | Config reads shall use the public anon key under RLS; **all writes** shall go through service-role edge functions that enforce the verified tenant claim | No anon write path exists | — |
| NFR-PORT-01 | Onboard players shall run on x86-64 and ARM64, headless, behind vehicle NAT | Multi-arch images; outbound-only MQTT | — |
| NFR-OPS-01 | The whole backend shall run **100% locally** with no cloud dependency | `db:start` applies every migration from empty; `stack:local` points everything at it | — |

---

## 9. Verification and traceability

| Mechanism | Coverage |
| --- | --- |
| **Automated test suite** | **581 engine tests across 67 files** (`node:test`, 96.5 % line coverage) **plus 758 front-end tests across 67 files** (Vitest + jsdom) — **1339 in total**, all passing. Covers trigger detection and prerequisite gates, custom fact triggers and geofence geometry, priority resolution and scheduling, the matrix-LED pipeline end to end (FNT, layouts, cycles, colour rules, mono/RGB FF, browser↔engine parity), pre-programmed destination lists, ADT payload conformance, multilingual variable resolution, per-element voice/volume, SSML, TTS resilience and the supplier HTTP clients, config env permutations and Postgres LISTEN reconnect, GTFS parsing and expected coverage, proof-of-play correlation/report/sink, project CRUD/migration/validation, and a full engine boot against a real broker. Breakdown: [`TEST-REPORT.md`](TEST-REPORT.md). |
| **Trigger-parity guard** | A **strict bidirectional contract** between the engine's trigger set and the authoring manifest — same types both ways, matching `local` flags, every MQTT trigger wired to an emitted event. Enforced by a contract test *and* a standalone CI step. This is what catches authoring/engine drift automatically. |
| **Typecheck** | `tsc --noEmit` gates the shared wire contracts (`src/shared/payloads.ts`) across engine **and** Angular — a contract change is a compile error on both sides. |
| **Sample-config test** | Every trigger resolves to a real playlist, every `{variable}` is one the resolver knows, every chime file exists, and **every playlist renders to real speech with no unfilled variables** — a playlist that would be silently dropped in production fails the build instead. |
| **End-to-end demos as living verification** | `proof:demo`, `ff:demo`, `led:demo`, `multi:demo` (N vehicles, asserts no cross-talk + served-set isolation), `bridge:test`, `config:test`, `prerender:demo`. |
| **CI** | Azure Pipelines runs typecheck + tests + trigger-parity + dashboard build on every PR and push to `main`, blocking merges on drift or a broken build. |

**Verification gaps:** no automated test drives the Go/Python players end-to-end against a broker; the FF hardware scroll frames are pinned byte-for-byte against the spec but have never been driven into a real sign; report integrity hashing is tested only at unit level.

---

## 10. Defect and gap register

The register is the product backlog's input. **Severity** is functional impact, not code quality.

### 10.1 Authored-but-inert — the author can configure something that does nothing

These are the highest-value findings: the UI implies a capability the runtime does not deliver, silently.

| # | Finding | Severity | Impact |
| --- | --- | --- | --- |
| D-01 | ~~**`bus-type` trigger never fires**, yet the Creator offers a City/Intercity/Regional/Shuttle/School multi-select for it.~~ **Fixed (authoring side)** — the multi-select is gone; the trigger is listed as **unavailable** with the reason, its toggle refuses a fresh enable, and an enabled legacy one is an **error** in Issues. The type stays in the catalogue so configs round-trip and engine↔manifest parity holds. It still cannot fire: that needs an upstream vehicle-type signal. | ~~High~~ | Resolved as far as the feed allows |
| D-02 | ~~**`exitSides` is declared but never read.** The engine fires `exit-side` on *any* change to a non-Unknown side.~~ **Fixed** — `exit-side` now fires only for an authored side; unset/empty still means every real side, and a feed `Both` satisfies a Left- or Right-only selection. | ~~High~~ | Resolved |
| D-03 | ~~**Arrivals variables** (`{arrivingBuses}`, `{arrivalMinutes}`, `{arrivalRoute}`, `{arrivalDestination}`) are offered in the Creator's variable picker but **are not implemented in the resolver**.~~ **Fixed** — removed from the picker; a **variable-parity contract test** now fails the build if the Creator ever offers a variable the resolver can't fill; the linter errors on any unresolvable token (typos included); and the engine logs unknown `{variables}` by name instead of dropping the announcement in silence. | ~~High~~ | Resolved |
| D-04 | **`requiresDirection` gate is declared but never enforced** — no forward/reverse signal exists in the feed. | Medium | Documented, but still authorable in the legacy app. |
| D-05 | Geofence modes **`entering` ≡ `within`** and **`leaving` ≡ `outside`** at runtime (the edge comes from the caller, so the four modes collapse to two behaviours). | Medium | Four options, two behaviours. Either implement true edge semantics or reduce the choice. |
| D-06 | ~~**`{distanceToStop}` is always spoken in metres**, ignoring the trigger's `distanceUnit`.~~ **Closed by decision (2026-07-22): spoken distance is always metres.** `distanceUnit` converts the **threshold** only — authoring "within 500 feet" still works — and the announcement speaks the feed's metres. The Creator now says so: the variable is labelled *Distance to stop (metres)* and choosing feet shows a note. Pinned by a test so it isn't "fixed" into feet later. | ~~Medium~~ — resolved (behaviour intentional) |

### 10.2 Capability regressions in the Angular Creator

The Angular Creator replaced the React app but does not yet expose everything the engine supports. Configs round-trip these fields safely (they survive import/export), but **there is no UI to set them**.

| # | Missing from the Creator | Engine support | Severity |
| --- | --- | --- | --- |
| D-07 | ~~**Universal prerequisite gates** (door open/closed, stop button, velocity below/above)~~ **Fixed** — a **Prerequisites** group on every trigger (the gates are universal, so it shows even for triggers with no condition of their own). The two door flags are one three-way control, so "open AND closed" cannot be authored; an empty speed box clears the gate rather than storing 0; and the linter errors on gate pairs an imported config may still carry (both doors, an empty speed window, a door gate contradicting `doors-open`/`doors-close`). | Fully implemented, incl. gate-hold retry | ~~High~~ — resolved |
| D-08 | ~~**Interior/exterior split playlists, sequences, repetitions**~~ **Fixed** — an **Audio** group in the expanded trigger row: single playlist or an interior/exterior split, either one a reorderable sequence with a repeat count. Switching back to single *clears* the channel playlists (leaving them would keep the engine playing them behind the UI's back), and the collapsed row summarises split/sequenced audio instead of showing a select that tells half the story. A `triggerJobsParity` contract test pins the Creator's preview to the engine's `resolveJobs`. | Fully resolved and rendered | ~~Medium~~ — resolved |
| D-09 | Custom-trigger `interruptLower` / `queueIfBlocked` | Honoured by the scheduler | Low |
| D-10 | Volume-rule `priorityOrder`; volume rule route/stop **ids** (only names are editable) | Honoured | Low |

*Mitigation today:* custom (fact-based) triggers can express most gate logic, since the facts include `doorOpen`, `stopPressed` and `speedKmh`. **But custom triggers bypass the gates, the settle window and the debounce entirely** (D-13) — so it is not a like-for-like substitute.

### 10.3 Correctness and semantics

| # | Finding | Severity |
| --- | --- | --- |
| D-11 | ~~**`last-stop` and `journey-approaching-last-stop` fire on the same state entry**… the vehicle announces the final stop **twice**.~~ **Fixed** — the two are mutually exclusive: `last-stop` (priority 8) wins whenever it is enabled and `journey-approaching-last-stop` yields to it; with only the latter enabled it fires as before. | ~~High~~ |
| D-12 | ~~`stop-skipped` has **two independent firing rules** (sequence jump >1, and `stopinfo.type = PASSAGE`) which can both hit for the same skip.~~ **Fixed** — the two sources now resolve to one decision (*which stop was skipped*) and each stop is announced at most once, including when PASSAGE and the pointer jump arrive in **different ingests** (different topics, so the common case). Serving a stop clears the memory, so a loop route that bypasses the same sequence number later still announces it. | ~~Medium~~ — resolved |
| D-13 | **Custom triggers bypass** universal prerequisite gates, the post-sighting settle window, and `matchTrigger`. | Medium — an author reasonably expects gates to be universal. |
| D-14 | The custom-trigger engine emits a `connectionPlatformKnown` fact that is **absent from the authorable fact catalogue**. | Low — capability exists, not exposed. |
| D-15 | Polygon point-in-polygon is **planar ray-casting** (no antimeridian / great-circle handling). | Low — irrelevant for transit-scale zones; would break at ±180° longitude. |

### 10.4 Security and audit integrity

| # | Finding | Severity |
| --- | --- | --- |
| D-16 | **Played-acks are QoS 0 and the broker's WebSocket listener is plain `ws://` with `allow_anonymous`.** Any device on the network can subscribe to a vehicle's audio — **or publish fake played-acks into the ADA audit trail**. | **Critical (before any real-vehicle deployment)** — this undermines the evidentiary value of the entire proof-of-play feature. Needs `wss://` + credentials. |
| D-17 | Because acks are QoS 0, **an unconfirmed dispatch is not necessarily an unplayed one** — coverage under-reports on ack loss. | High — the report's basis disclaimer must say so (it does), but the transport should be made reliable. |
| D-18 | **The Azure Speech key was shared in plaintext during development** and should be rotated. | High — outstanding. |
| D-19 | The legacy React app carries a **hardcoded dev broker password** in `DEFAULT_MQTT_CONFIG`, which ships in its client bundle. | Medium — that app is no longer served by the portal, but the credential is still in the repo. |
| D-20 | ~~`published_by` in the audit trail is **just the tenant string**~~ **Partly fixed — the integrity half.** `published_by` is now derived from the caller's **server-validated** token whenever a user is behind the request (the client's own value is ignored in that case), and every row records `published_by_verified` so a self-asserted label can never be read as evidence of a person; History marks the two differently. The Creator still has **no login of its own** — it accepts a token from a host shell (`SessionService`, the identity twin of the tenant seam), so real names appear the day the suite signs users in. Until then rows stay honestly *unverified*. | ~~Medium~~ — integrity resolved; naming a person still needs an identity provider |

### 10.5 Functional gaps (known, accepted)

| # | Gap | Note |
| --- | --- | --- |
| D-21 | **No player drives the exterior loudspeaker.** The payload carries `speakers.EXTERNAL`, but driving it also means raising a digital output to enable that amplifier — unit-specific wiring. An exterior-only announcement plays at **zero gain** rather than out of an exterior speaker. | Requires vehicle integration |
| D-22 | **FF hardware scroll** — the byte layout is settled (03090 R5 §7.3.3: four settings bytes, `[mode, count, time, speed]`; R7's two-byte Example 3 is an error R5 removed) and the interior sign now emits `0xA5`/`0xD5` scroll frames. Exterior signs remain static — a scrolling destination is a sub-region, needing a second text field per frame. | Implemented (interior); needs bench verification |
| D-23 | **Audio ducking on interrupt** — the superseded clip hard-stops; no cross-fade. The engine does not own the speaker. | Accepted |
| D-24 | **MP3 concatenation is a byte-wise join** — plays fine everywhere; sample-accurate gapless needs ffmpeg. | Accepted |
| D-25 | **Per-element volume applies only where the supplier declares it** (capability descriptor: Azure/Google/Polly); plain-text suppliers drop it with a log line + an authoring-time warning. | Accepted |
| D-26 | Players use only `audio[0]`; multi-clip `audio[]`, `contentUrl`, and `expiryDateTime` are unhonoured. | Accepted |
| D-27 | Per-vehicle state is **in memory** — horizontal scale needs sharding or Redis. | Not a functional need at 2000 (NFR-SCAL-01) |
| D-28 | **`engine/fleet` payload is O(fleet)** (~940 KB at 4000 vehicles, republished every second) — should be gated/decimated when nobody is subscribed. | Scale hygiene |
| D-29 | **Retained-topic startup flood** — reconnecting against a large fleet replays ~5 retained topics × N vehicles at once, causing multi-second lag spikes at 2000+. | Scale hygiene |

### 10.6 Documentation drift

| # | Finding |
| --- | --- |
| D-30 | ~~`ARCHITECTURE.md` states **140 tests across 25 files** and **34 backend modules**, and documents neither `proof_of_play` (durable audit sink), the `/proof/report` certified coverage report, nor GTFS expected-coverage.~~ **Fixed (2026-07-24)** — the whole doc set was reconciled against the code in one pass: counts re-measured (**581 engine tests / 67 files / 65 modules**, **758 front-end tests / 67 files**), the module map and responsibility table extended to the matrix-LED pipeline, custom triggers, destination lists, the proof sink/report and GTFS, the MQTT and HTTP tables completed (`display/signs`, `list/destinations`, `/proof/report`, `/docs`, the auth gate), and the screen/nav descriptions brought in line with the merged cockpit and the split Proof / History menus. `TEST-REPORT.md` is now generated from the runner, case for case. |
| D-31 | The **`config_versions` publish-history table** and the `parse-trigger-rule` edge function are live but described only in the Creator's own screens documentation, not in the architecture's interface section. | Low — authoring-side surface, no engine contract. |

---

## 11. Roadmap

Ordered by the register above, not by engineering convenience.

**Now — trust and safety (blocks any real-vehicle deployment)**
1. **Secure the transport** (D-16, D-17): `wss://` + credentials on the broker; make played-acks reliable (QoS ≥1). *Without this the proof-of-play trail is forgeable, which negates BO-3.*
2. **Rotate the Azure Speech key** (D-18); purge the hardcoded broker password (D-19).
3. ~~**Fix the authored-but-inert set** (D-01, D-02, D-03): either wire them up or remove them from the UI.~~ **Done** — the authoring surfaces are gone or flagged. `bus-type` and the arrivals data still need upstream signals before they can *do* anything (items 10 below).
4. ~~**Fix the double-announce** on the final stop (D-11).~~ **Done.**

**Next — close the authoring regression**
5. ~~**Restore prerequisite-gate authoring** in the Creator (D-07) — the engine's headline safety gating is currently unreachable from the UI.~~ **Done.**
6. ~~**Sequence / repetition / split-playlist authoring** (D-08).~~ **Done.**
7. **Per-user identity** on publish and proof (D-20). *Publish half done:* identity is verified server-side when a token is present and every row says whether it was. What remains is an identity **source** — either the suite hands the Creator a signed-in user's token, or the Creator grows its own login (who provisions accounts is the open question). Proof-of-play records are untouched by this.

**Then — completeness**
8. FF hardware scroll (D-22) — verify the interior frames on a bench sign, then extend to the exterior destination sub-region.
9. Exterior-speaker enablement (D-21) — needs vehicle integration.
10. `bus-type` and stationary arrivals — once the feed exposes formation/type and real-time arrivals.
11. Per-tenant proof retention/rotation.

**Scale (not required for 2000)**
12. TTS throughput tier (the remaining real wall: Azure F0 caps ~20 req/s).
13. `engine/fleet` decimation (D-28); paced retained-topic backfill (D-29).
14. Horizontal sharding / Redis-backed state for headroom and availability beyond ~3000.
15. ffmpeg gapless concatenation (D-24).

**Housekeeping**
16. ~~Reconcile `ARCHITECTURE.md` with the current system (D-30).~~ **Done (2026-07-24)** — every
    document re-checked against the code; the remaining sliver is D-31 (authoring-side tables).

---

## 12. Glossary

| Term | Meaning |
| --- | --- |
| **PIS-PT** | Passenger Information System — Public Transport. The inbound MQTT trip-data protocol (multi-topic, retained). |
| **ADT 4.x** | The transHub audio message spec published on `…/tts` — base64 audio plus per-speaker volumes. |
| **Tenant** | An operator/agency namespace (e.g. `baltimore-md-mta`); the first topic segment and the isolation boundary. |
| **Fleet** | A sub-scope within a tenant. One config is published per `(tenant, fleet)`. |
| **Journey** | One scheduled run of a vehicle, identified by `vehicleJourneyRef`; the natural grouping for proof-of-play. |
| **Trigger** | A rule that fires an announcement on a PIS-PT condition. 33 built-in types, plus custom fact-based triggers. |
| **Custom trigger** | An author-composed boolean expression over live facts and geofences — the extension point when no built-in trigger fits. |
| **Project** | A named, self-contained configuration. The **authoring input**. |
| **`engine_config`** | The **published output** — the single row the engine actually reads. |
| **Playlist** | An ordered list of elements rendered into one announcement. |
| **Served set** | The vehicles the engine currently renders for (operator-controlled, retained). Empty = silence. |
| **Proof of Play** | The durable record of what was played/shown, where and when — dispatch **confirmed by the vehicle**. |
| **Dispatched vs Played** | Dispatched = the engine published a clip. Played = the vehicle acked *after the clip finished*. Only the latter is evidence. |
| **Coverage** | Confirmed plays ÷ correlatable dispatches. Records with no `traceId` are excluded from the rate. |
| **FF / Mobitec 1463-L** | The LED sign driver-board serial protocol; font `w` is its graphic (bitmap) font. |
| **Hanover HCPS / SuperX** | Hanover Displays' sign protocol: HCPS frames (`STX…ETX` + two ASCII-hex checksum chars) carrying SuperX messages; `{\pic}` embeds a bitmap as a vertical raster. Specs in `Hanover/`. |
