MASTER issue. The design below is the single source. One sub-issue per platform, in dependency order:
| Sub-issue | Platform | Phase |
|---|---|---|
| #579 | hydranps service | 1: upsert, submit_token, batch, admin split, card spec, token rotation |
| #580 | hydracluster | 2: nps config to heads, closeSession backstop |
| #581 | hydraheadflatscreen + Qt kiosk | 3: visitor exit signal, rating card, queue |
| #582 | hydraheadipad | 4: ZStack card, UserDefaults queue |
| #583 | hydraheadquest | 5a: flat panel card, no XR |
| #584 | hydraneckwebrtc (browser) | 5b: score_scale on relay records |
#579 blocks everything. #580 blocks the heads. #581 and #582 can run in parallel after #580. hydraheadmacos has no sub-issue: the app is a stub, building it at all is a prerequisite.
Close this master when all six are done and the dashboard shows head sourced records from every platform.
Ask the visitor one question at the end of an experience: "How was it?" Show five stars. Accept one tap. Return to the catalog.
The model is the Google Meet end of call prompt. It appears right after the visitor taps "Exit experience", it is small, it is optional, and it goes away by itself.
Every session that ends in a way the head can observe must produce one record in hydranps, rated or not. This is a best effort invariant, not a guarantee. An app kill, a power loss or a device sleep in mid stream leaves the head with nothing to submit, and those are exactly the sessions that ended badly, so a head only denominator biases the rated versus total metric upwards. To close most of that gap, hydracluster also submits a score: null record when it closes a session the head cannot report (see "Cluster side backstop"). Records that neither side can produce stay lost, and the dashboard must be read with that in mind.
The store already supports the unrated case: store.Rating.Score is a *int and nil means "not rated" (/home/claude-user/hydranps/internal/store/store.go:15).
Disconnected sessions do not affect the NPS numbers. A session that ends with end_reason error, timeout or watchdog measures reliability, not satisfaction. Those records are stored and counted, but they never enter the rating rate or the average score, even when they carry a score (the browser overlay opens on disconnect, so a score given there rates a broken stream). The satisfaction metric is: rated versus total over sessions with end_reason: quit. Disconnects get their own count on the dashboard.
Build the popup natively on each head. Do not build a shared web overlay served by hydranps.
The analyses support only the native option. No head except the browser path has a webview: the flatscreen kiosk is Qt Quick with QML loaded from qml.qrc, the iPad is SwiftUI plus a UIKit Moonlight modal, and the Quest builds its whole UI in Kotlin code with no XML and no Compose. hydranps also has no visitor facing template, no CORS middleware anywhere in the repo or its Hydra dependencies, and Go 1.22 method scoped mux patterns that answer a preflight OPTIONS with 405, so a cross origin browser POST cannot work today.
Tradeoff: three native implementations cost more code and three release trains instead of one page. In exchange each popup uses the input model, the layout scale and the dark palette the platform already ships, and no hydranps credential ever lands in a view layer. The browser path is the exception and stays as it is: hydraneckwebrtc already renders a working star widget in internal/proxy/inject.go.
The design is reused, the code is not. One canonical spec file, docs/design/rating-card.md in the hydranps repo, defines everything a visitor can see or feel:
#80000000, card #18181b, filled star #eab308, empty star #3f3f46, text #fafafa.inject.go, copied into the spec as the single source.Each head repo implements the spec in its own toolkit (QML, SwiftUI, HydraUi) and links to it from its own docs. A change to the card is a change to the spec first, then one small commit per head. Each head testbook gets one step: "compare the card against the spec". hydranps serves the spec at GET /api/v1/runbook style, but the heads do not fetch it at runtime; it is a build time contract, not a remote asset. The spec file lands in Phase 1.
This is the reuse ceiling until there is a shared UI layer across heads. The heads are deliberately separate codebases per platform, so pixel identical rendering is not a goal; same copy, same colors, same timings, same behavior is.
Appearance. A centred card over the catalog grid, on a dimmed scrim. Title "How was it?", one row of five stars, a "Skip" text button. Nothing else. No comment box, no follow up question. Filled star #eab308, empty star #3f3f46, matching the browser widget.
Timing. The popup opens after the stream is fully gone and the catalog is visible again, never over a tearing down stream. It opens only for a visitor initiated exit, and only when the head received an explicit visitor exit signal. It never opens on an inferred exit.
Tap. One tap on star N submits and swaps the card content to "Thanks" for 1200 ms, then dismisses.
Auto dismiss. 10 seconds with no tap dismisses the card. That counts as no response. The record is still submitted with score: null. A visible countdown is not shown.
Skip. "Skip" dismisses at once and submits score: null. A tap on the scrim outside the card does the same.
Never blocking. The catalog is fully rendered and live behind the card. On every platform the card is an overlay in the already restored catalog view, so re entry is possible even if the popup logic fails. The card owns no exclusive input mode, no gesture requirement and no network wait: submission is asynchronous and dismissal never waits for a response.
Not shown for. Operator stop (stream-stop CLI, any POST /api/v1/stream/stop that does not declare a visitor source), central assignment clear, cluster initiated terminate, crash, watchdog timeout, and experience switch. Those still produce a record with score: null and the matching end_reason, submitted by the head when it observed the end and by hydracluster when it did not. No visitor sees a prompt.
Why an explicit signal is required. The kiosk cannot tell a visitor exit from an operator, cluster or body driven end by watching the stream stop:
POST /api/v1/stream/stop (localapi.go:169) is one bodiless route shared by the visitor overlay (hydra-experiencenet/app/gui/StreamOverlay.qml, the session === null branch XHRs exactly that URL) and by the operator CLI (hydraheadflatscreen/internal/cli/stream.go:55).transitionStreamState("finished") fires for every session end that is not a stage or launch failure (KioskStreamPage.qml:95-99), so a finished push says nothing about who ended it.hydracluster/pkg/api/handlers_head.go:607 POSTs to the body's localhost:47991.So the card would pop on unattended kiosks and end_reason would be mislabelled quit. The fix is in the head integration section: an explicit source on the stop request and a distinct quitStarting push from the kiosk.
Per platform notes.
| Platform | Card | Notes |
|---|---|---|
| Flatscreen kiosk (Linux, in process) | QML Rectangle scrim #80000000, z: 100, inner card #18181b |
Copy the existing help dialog block in KioskView.qml. Touch only, no keyboard. |
| Flatscreen kiosk (macOS, subprocess) | Same QML card in the surviving kiosk grid process | The stream process calls Qt.quit() at exit, so the card cannot live there. |
| iPad | SwiftUI ZStack sibling in ContentView with .zIndex(14) |
Not a .sheet. ContentView.swift:81-83 records that sheets produced a blank black screen on iPad. Card styled like OperatorPinView, 68x56 tap targets, must lay out in portrait and landscape. |
| Quest | Panel built in code with HydraUi, raised by HydraCatalogActivity.render() |
No AlertDialog. Minimum 64 dp targets, panel scale about 2.3x. Must be raised on the UI thread after the catalog activity is foreground. |
| Webstream (browser) | Existing overlay in hydraneckwebrtc/internal/proxy/inject.go |
No new UI. See Rollout phase 5. |
| macOS head | None | Out of scope, the app is a 30 line stub. |
Extend store.Rating in /home/claude-user/hydranps/internal/store/store.go. All new fields are optional with omitempty, so existing ratings.yaml records load unchanged and no migration is needed.
| Field | Type | Source | Notes |
|---|---|---|---|
id |
string | hydranps | 8 byte hex, generated on insert. |
session_id |
string | head or cluster | Required. Now also the dedup key. |
experience |
string | head | Catalog entry name, for example mercator-talks. |
score |
*int | head | 1 to 10, or null for no response. |
score_scale |
string (new) | head | 5-star from a head popup, 10-half-star from the browser widget. |
head_id |
string (new) | head | node-<8 hex> cluster node id. Also the ownership key on update. |
head_type |
string (new) | head | hydraheadflatscreen, hydraheadipad, hydraheadquest. |
venue |
string (new) | head | Venue key from head config. |
district |
string | head | Existing field. |
body_id |
string (new) | head | Cluster node id of the body. |
body_ip |
string | head | Resolved stream host. Empty for the Quest if unknown. |
end_reason |
string | head or cluster | quit, timeout, error, shutdown, watchdog. |
duration_ms |
int64 | head | From the head side stream start timestamp. |
ice_candidate_type |
string | relay only | Left empty on Moonlight paths. |
disconnect_count |
int | relay only | Left 0 on Moonlight paths. |
client_submitted_at |
RFC3339 string (new) | head | Set only when a queued record is flushed late. |
created_at |
time.Time | hydranps | Server time on first write. |
End reason values. quit means the head received an explicit visitor exit signal. timeout is a stale in process stream reaped by the head. error is a failed or crashed session. shutdown covers an operator stop, an agent stop, a preempting start, and any observed end with no visitor signal, which includes a cluster initiated terminate. watchdog is written only by hydracluster.
Score scale. The wire field stays on the 1 to 10 scale so handleCreateRating validation, the halfStars template func and the avg X/10 display all keep working. A head popup sends score = stars * 2 and sets score_scale: "5-star". Head ratings therefore never produce odd values, which is expected and is why score_scale exists. Averages from both sources stay comparable because both normalise to 10.
Session id. No head has one, and the cluster's SessionRecord.ID lives only in memory and is never sent to the head. The head mints it at stream start: <head_id>-<unix milliseconds at start>. It is opaque to hydranps. hydracluster uses its own SessionRecord.ID for the records it writes, so a head record and a cluster record for one physical session do not share a key and cannot merge. That is a known limit of the backstop, and the fix is the improvement already flagged: have hydracluster return its own session id on DELETE /api/v1/heads/{id}/stream so both sides key on the same value. Verify during implementation.
POST /api/v1/ratings/home/claude-user/hydranps/internal/api/handlers.go.
Auth: Authorization: Bearer <token> accepting either server.admin_token (unchanged) or the new server.submit_token. The submit token is accepted on this route only. It cannot list, cannot delete and cannot open /admin.
Request (createRatingRequest, extended):
{
"session_id": "node-8acd8c19-1756298400123",
"experience": "mercator-talks",
"score": 8,
"score_scale": "5-star",
"head_id": "node-8acd8c19",
"head_type": "hydraheadflatscreen",
"venue": "cloud-seven",
"district": "bxl1",
"body_id": "node-11da9ea3",
"body_ip": "10.10.4.21",
"end_reason": "quit",
"duration_ms": 415000,
"client_submitted_at": "2026-08-27T14:21:03Z"
}
Validation, unchanged plus additions: session_id required and non empty, score must be 1 to 10 when present, score_scale must be empty, 5-star or 10-half-star.
Behaviour change, mandatory: upsert by session_id. If a record with the same session_id exists, update it in place and return 200. Otherwise insert and return 201. The reason is retry idempotency, and that reason alone is sufficient: a POST that times out on the wire but succeeded on the server must be safe to send again, and the offline queue replays records the head cannot prove were stored. It needs a new Store.UpsertBySessionID next to AddRating; nothing looks a record up by session id today.
Duplicates come from one source only: a head resending its own record. A head write and a relay write can never collide, because the head mints <head_id>-<unix ms> while hydraneckwebrtc mints its own id inside the relay, and the Moonlight direct heads never involve the relay. So no field level merge rules are defined. An update replaces the submitted fields of the stored record with the incoming values. id and created_at never change.
Ownership check on update. If the stored record has a non empty head_id and the incoming head_id differs from it, including when the incoming value is empty, reject the write with 409 and change nothing. Without this rule the upsert key is a client supplied, guessable id behind one shared write token, so any head, or anyone who extracts that token from a head config, could silently rewrite an existing record's score, experience, venue and head attribution. Head identity is deliberately an unverified body field, so the store must enforce that an upsert only ever amends the head's own session. Records written by hydracluster carry the head id of the head they belong to and follow the same rule.
GET /api/v1/ratingsAdd ?venue=, ?head_id= and ?since=<RFC3339> filters next to the existing ?experience=. Admin token only.
POST /api/v1/ratings/batchAuth as above. Body is a JSON array of the same object, maximum 50 entries. Each entry runs through the same upsert, including the ownership check. Returns 200 with {"accepted": n, "rejected": [{"session_id": "...", "error": "..."}]}. This is the flush path for an offline queue. Without it a head with 20 queued ratings makes 20 sequential authenticated posts.
/adminAdd head, venue and body columns to internal/web/templates/admin.html. Add a venue filter next to the existing client side experience filter. Fleet wide head traffic will make the unpaginated single table unusable long before the store breaks, so add a ?limit= (default 500, newest first) to handleAdmin.
The limit must not touch the summary. handleAdmin computes Total, Rated, AvgScore and RatingRate inline over the full Store.All() slice today. Keep the full set scan: compute the summary values over the whole set first, then slice the newest limit records for the rendered table rows only. A limit that also narrowed the summary would silently redefine the headline metric, so the page states the row count next to the table ("showing newest 500 of N") to make the difference visible.
Split the summary by end reason. Rated, AvgScore and RatingRate are computed only over records with end_reason: quit. Records with end_reason error, timeout or watchdog are shown as a separate Disconnects count (with their own average nowhere: their scores are ignored). shutdown records count in Total only. This is the dashboard side of the rule that disconnected sessions do not affect the NPS numbers.
DELETE /api/v1/ratings/{id}, GET /api/v1/health, GET /api/v1/runbook, login and logout. No CORS is added, because no browser on a head origin will call hydranps.
server:
admin_token: "<existing>"
submit_token: "<new, write only>"
Both deployment paths must tolerate it: the systemd host install and the Incus scale image that runs serve --dev --listen :8080. An empty submit_token disables that credential and only the admin token works.
Two repos, two releases. Keep the token in Go, per the "Qt is view, Go is cluster liaison" rule.
Go agent, /home/claude-user/hydraheadflatscreen/pkg/client/localapi.go.
In startStream(), next to newStreamParams(), mint sessionID and record streamStartedAt.
Add a pendingRating struct on LocalAPI holding session id, experience (a.streamApp), body id (a.cachedBody.ID), body ip (a.streamHost), start time, end reason and a deadline. Snapshot it before the resets. handleStreamStop, the finished and error branches of handleStreamState, LocalAPI.Stop() and reapStaleInProcessStream() all zero streamApp, streamHost and preparedParams, and invalidateBodyCache() drops cachedBody. Without the snapshot the context is gone before the visitor sees a star.
One consumer per session. The snapshot is taken through a compare and swap, takePendingRating(reason), under the existing mutex: the first end handler to reach it consumes the pending state and labels the end reason there, and every later handler gets nothing and does no work. This matters on macOS and Windows, where two handlers run for every stopped session: handleStreamStop kills the subprocess and resets state, then the waitForStreamExit goroutine started at localapi.go:301 wakes, sees the SIGKILL as a non zero *exec.ExitError, and sets status error after the stop already set idle. Without the compare and swap that one session yields two cards and two conflicting end_reason values.
Explicit visitor source, no inference. Two changes make a visitor exit distinguishable:
POST /api/v1/stream/stop accepts an optional JSON body {"source": "visitor"} or {"source": "operator"}. A missing, empty or unparsable body means operator. The operator CLI (internal/cli/stream.go:55) keeps sending a bodiless request and is therefore labelled operator with no change. The Qt overlay's session === null branch in StreamOverlay.qml sends {"source": "visitor"}.quitStarting the moment the visitor triggers the exit, before the stream tears down. The agent records "a visitor signal was seen" for this session.End reason labels then follow from the signal, not from inference: quit only when the consumed session had a source: visitor stop or a quitStarting push; a finished push or a zero subprocess exit with no visitor signal is shutdown, which is what a cluster initiated terminate and an operator stop both land on; a non zero subprocess exit in waitForStreamExit() is error; reapStaleInProcessStream() is timeout; LocalAPI.Stop() and a preempting handleStreamStart are shutdown.
When the consumed reason is quit, POST http://127.0.0.1:9741/api/v1/rating/show to the Qt app with {"experience_label": "...", "timeout_ms": 10000}. This one trigger covers both the Linux in process path and the macOS subprocess path, and it keeps the decision of "was this a visitor exit" in the agent, which is the only component that knows.
Agent side expiry, mandatory. Set the pending rating's deadline to timeout_ms plus a 5 s grace when the show request goes out. A single agent timer fires at the deadline and, if the pending rating is still there, consumes it, submits score: null with the labelled end reason, and clears the pending state. Without this backstop a Qt crash, a killed kiosk or an agent restart between /rating/show and the reply leaves the record for an ended session unsubmitted, and, because isStreamIdle() returns false while a rating is pending (step 8), a stuck pending rating would block every Qt app update for ever. The expiry runs whatever the Qt app does or does not do.
New route POST /api/v1/rating on 127.0.0.1:9740, body {"stars": 1..5} or {"stars": 0} for skip and timeout. The handler maps score = stars * 2, or nil when stars is 0, enriches from pendingRating plus c.HeadID, c.headConfig.District and c.headConfig.Venue, and hands it to the submitter. A reply that arrives after the expiry already fired is dropped, because the pending state is gone.
New file pkg/client/nps.go, a copy of /home/claude-user/hydraneckwebrtc/internal/client/nps.go (NPSRecord and SubmitNPS(baseURL, token, rec)), extended with the new fields.
QtAppUpdater.isStreamIdle() must return false while a rating is pending, or an update can pkill the kiosk out from under a visible card. The guard is bounded by the step 6 expiry and can never hold longer than timeout_ms plus the grace.
Config: add nps_url and nps_token to headConfig in internal/cli/run.go. An empty nps_url disables the feature, matching how hydraneckwebrtc gates on cfg.NPS.URL != "".
Qt app, /home/claude-user/hydra-experiencenet.
app/api/localserver.cpp: add POST /api/v1/rating/show next to /api/v1/overlay/show, emitting a new ratingShowRequested(QString label, int timeoutMs) signal on localServerBridge (declared in localserver.h, registered in main.cpp:1164).app/gui/StreamOverlay.qml: the session === null branch keeps posting to /api/v1/stream/stop, now with the body {"source": "visitor"}.app/gui/KioskStreamPage.qml: push quitStarting when the visitor triggers the exit, before the existing teardown. Leave the transitionStreamState("finished") push at :95-99 as it is. finished keeps meaning "the session ended", and no longer carries any claim about who ended it.app/gui/KioskView.qml: add a Connections block on localServerBridge for ratingShowRequested, and a ratingVisible card modelled on the existing help dialog block. On star tap, skip, scrim tap or the 10 s timer, fire and forget XMLHttpRequest to http://127.0.0.1:9740/api/v1/rating, exactly like postStreamState().KioskView.fileIssue() posts straight to an external service today, but that pattern would ship a credential in the AppImage and the DMG.Windows heads have no kiosk UI (startKiosk() is a stub in moonlight_windows.go), so they submit score: null records only. The compare and swap of step 3 applies there as well.
/home/claude-user/hydraheadipad.
AppState.startStream(...): mint sessionID and store streamStartedAt. StreamSessionBridge.connectedAt already exists and is a better duration anchor: prefer it and fall back to the start time. Verify during implementation which one the runbook's duration line uses.AppState.stopStream() (AppState.swift:362): capture streamingExperience and the bodyID from case .streaming before the existing streamingExperience = nil line. Keep await client.notifyStreamStopped(bodyID:) exactly where it is and awaited. The body release race fixed in v0.2.146 must not be reordered.pendingRating and then state = .selfService(experiences) as today. Do not add a HeadState case. HeadState switches in tick(), statusString and liveBodyID are exhaustive, and tick() reassigns state every 30 s, which would fight a rating state. An overlay driven by an @State var pendingRating in ContentView at .zIndex(14) avoids all of that.pendingRating through a single consume helper, so one ended session yields one record even when an error path and the stop path both run. Give it the same deadline expiry as the agent: 10 s plus a 5 s grace, after which the app submits score: null and clears the overlay.showSessionInterrupted() and showError(_:) submit a record with score: null and end_reason error, and show no card. Those paths land on ErrorView.HydraStreamSession.stop(). Presenting a view controller while the exit action sheet is still animating out is the exact crash documented at runbook line 391.Services/HydraNPSClient.swift. Remember the four project.pbxproj entries or it is silently not compiled.AppState.makeDiagnosticsConfig() already assembles serverURL, headID, token, district and venue. Reuse that bundle./home/claude-user/hydraheadquest.
State.Streaming is entered, and mint the session id there.HydraState.onUserStop() (HydraState.kt:421) is the visitor exit. Set a pending rating before setState(State.SelfService(...)). onStreamEnded() (:463) is an in stream exit and also counts as quit. onStreamInterrupted() (:441) is error and shows no card. Consume the pending rating through one guarded helper, and expire it after 10 s plus a 5 s grace with score: null.HydraCatalogActivity.render() or onResume, never from the HydraState executor thread, and marshal with runOnUiThread. Game and GameXR are noHistory activities, so the catalog is foreground only after they are destroyed.HttpURLConnection helper modelled on HydraIssueReporter, but non blocking and non throwing.onUserEndXrSession(), :1051) are in scope for the same treatment but ship last: verify during implementation that the XR panel can host the card.No change in /home/claude-user/hydraheadwebstream. The browser leaves that origin at stream.html:497. The rating already works downstream in hydraneckwebrtc/internal/proxy/inject.go. Phase 5 only adds score_scale: "10-half-star" to the NPSRecord built in internal/cli/worker.go:152-178, so the two sources are distinguishable on the dashboard.
Out of scope. hydraheadmacos is a black window with two lines of text and no cluster client at all.
URL discovery. Heads must not hardcode hydranps.experiencenet.com. hydracluster adds nps_url and nps_token to headConfigResponse (/home/claude-user/hydracluster/pkg/api/handlers_head.go:16-47), filled from a new nps: block in serverConfig (internal/cli/cluster/serve.go) using the same wiring pattern as SetExperienceLibrary. The precedent is exact: experience_library_url is already delivered this way to heads, and BodyConfig already carries ExperienceLibraryURL plus its token to bodies. Adding fields to headConfigResponse is safe: the iPad HeadConfig, the flatscreen client.HeadConfig and the Quest HydraModels all decode unknown fields tolerantly. Do not move this into the head PUT heartbeat, which round trips head state.
Cluster side backstop. The same nps: block gives the cluster server itself an nps_url and token. On closeSession (session_watchdog.go) hydracluster submits a score: null record for every close the head cannot report: a watchdog timeout (end_reason: "watchdog"), a central assignment clear and a cluster initiated terminate (end_reason: "shutdown"). It fills session_id from SessionRecord.ID, head_id from the head it closed, plus body id, district and venue from the node record, and it leaves experience empty until SessionRecord.ExperienceName is populated. The submission is best effort and never blocks the close path. This is what stops the denominator from dropping exactly the sessions that ended badly. It does not cover a head that dies silently with the session still open, and it does not merge with a head record for the same physical session, because the two sides mint different session ids. Both limits are called out under "Session id".
The token is submit_token, not admin_token. It arrives over the head's authenticated config fetch, so it is never baked into an IPA, an APK or a DMG. It can only write ratings, and the ownership check on update means it can only ever amend the writer's own head sessions. A compromised head can post junk ratings for itself and nothing else: it cannot list, cannot delete, cannot open /admin, and cannot rewrite another head's record. The flatscreen agent may also take nps_url and nps_token from local config as an override, which is what makes the agent testable before the cluster change lands.
Auth to hydranps is Authorization: Bearer <submit_token>. Head identity is carried explicitly in the head_id body field, never inferred from the bearer token. That follows the standing rule: head identity is always an explicit parameter. It also means head_id is unverified on first write, which is why it is treated as an ownership claim on update only.
Edge route. hydranps is already reachable at hydranps.experiencenet.com, published through the user.hydra.domain and user.hydra.port labels that hydraskin reports to hydracluster and hydrascalerouter turns into a Traefik route. Nothing new is needed, and no static edge route is to be pinned. iPad heads reach it through the venue hydraneck, Mac Mini heads through their own WireGuard peer.
Offline: queue with bounded retry, not fire and forget. The submission never blocks the UI, so it is fire and forget from the visitor's point of view, but a failed POST is not dropped.
<DataDir>/nps_queue.jsonl. On each 30 s Client.tick(), flush up to 50 records through POST /api/v1/ratings/batch and truncate on success. Drop a record after 24 hours or once the file passes 1000 lines, oldest first. JSONL on disk, not a database, per the repo rule.UserDefaults, flushed on the heartbeat timer and at launch.hydra SharedPreferences.Retries are safe because POST /api/v1/ratings upserts on session_id, and a retry always carries the same head_id, so it passes the ownership check. A record flushed late sets client_submitted_at so the real moment is not lost behind the server's created_at.
Phase 1: hydranps. New Rating fields, Store.UpsertBySessionID with the head id ownership check, extended createRatingRequest and validation, submit_token, /api/v1/ratings/batch, admin columns and row limit with a full set summary split by end reason (quit only in the NPS numbers), the card spec file docs/design/rating-card.md, runbook update. Add the first _test.go files in this repo covering scoring, validation, upsert, the ownership rejection and the summary versus limit split: go test ./... is already a release gate and currently passes over zero tests. Fix the known drift while here: the documented server.listen key that Config does not have. The live tokens are already removed from docs/testbooks/nps-e2e.md (commit d14b3bc, the testbook now reads them from the servers over SSH), but they remain in git history, so rotate both tokens in this phase. Verify: curl a head shaped record, re post it and confirm one row, then post the same session_id with a different head_id and confirm 409 and an unchanged record.
Phase 2: hydracluster. nps: config block, nps_url and nps_token on headConfigResponse, and the closeSession backstop submission. No head behaviour change. Verify with GET /api/v1/heads/{id}, and by forcing a watchdog close on a test body and confirming one watchdog record on /admin.
Phase 3: flatscreen and Qt kiosk. Agent first (session id, pending rating with compare and swap and expiry, source on /api/v1/stream/stop, end reason labels, POST /api/v1/rating, queue, isStreamIdle guard), then the Qt card, the quitStarting push, the source: visitor stop body and the :9741 route. Both sides must tolerate the other being old: an agent that gets a 404 from /api/v1/rating/show simply submits score: null, and an old Qt app that sends a bodiless stop is labelled operator and shows no card, which is the safe default. Tag the Qt app and the agent separately. Test on chunky-turnip-23 in bxl1-test, with cheeky-cactus as the kiosk head and cosmic-pretzel-98 as the streamer for in venue verification. Never on production bodies. Test the operator path explicitly: run stream-stop from the CLI and confirm no card appears and end_reason is shutdown. Test the expiry: kill the Qt app between /rating/show and the reply, and confirm a score: null record lands and the next Qt update is not blocked.
Phase 4: iPad. HydraNPSClient, session id, pending rating capture and expiry, the .zIndex(14) overlay, the UserDefaults queue. Add a rating step to docs/testbooks/testbook.md step 8, and regression check step 9 (mic enabled exit). Verify remotely with the admin screenshot: window.drawHierarchy captures SwiftUI and UIKit, so the card is visible in it.
Phase 5: Quest, then webstream alignment. Quest flat panel first, XR last. Then add score_scale: "10-half-star" in the hydraneckwebrtc OnSessionEnd callback so the dashboard can split the two sources.
Each phase ships and is validated end to end before the next starts. Do not pause between phases if the checkpoints pass.
submit_token plus the head id ownership check is the scope here. Signed head identity is the proper fix and is separate work.SessionRecord.ExperienceName in hydracluster, and persisting the in memory session store. The head supplies the experience directly, so neither blocks this feature. The cluster backstop record leaves experience empty until then.