Context
The film's A6 narration (#15252) opens with a hook over the window birth and then the team's introductions — the operator's steer of 2026-10-10 20:4xZ (via Emmy): "our self-intros and matching video/voice content are crucial; more content from us is optional". Emmy's measured bindings (20:55Z) put the introduction's picture between the verified birth and the journey (Continue released no earlier than birth + 20.95 s) and ask for a living-room explanatory interval after the native return (M06 + E02 = 8.4 s before the rail's pin). The take at PR #19570's head gives those passages ≈ 1 s each: the committed breath, then the journey's gate resolves at once; the whole-again beat, then the rail's pin.
Design authority: the operator's steer above, within the 2026-09-23 pacing rule (no gesture or pause stretched to fit a line) and the screenplay's own gate contract — "a gate is a wait, not pacing" (WorkstationFilmTourNL third test). An introduction or an explanation over the living room is a designed shot with its own content, not a stretched pause; the question is only where its time comes from, and the answer must leave the in-app viewer's tour and the headless witnesses untouched.
The Problem
Under autoGates the controller's step settlement returns as soon as the cue and the refresh settle: replays and takes never wait anywhere. The two shots sit exactly at existing steps — the second gate's step (film-journey[0], right after the committed birth) and the whole-again beat (film-reintegration[2]) — but the take cannot hold there. The alternatives are worse: cue-free pauses of 19 s and 8 s in the screenplay break the 1.5 s reading-beat bound that fiveBeatFilm.spec pins and make every in-app viewer wait; a viewer-mode gate in the take renders the Continue button into the recording (TourToolbar's workstation-tour-continue, bound to tour.gatePrompt) and the return has no gate at all.
The Architectural Reality
apps/workstation/view/TourController.mjs: autoGates is a plain field set through startTour({autoGates}) / runVisibleTour or directly before Start by a transport; settleTourStep(data) is the host's settlement barrier the runner awaits per step (TourRunner → stepSettlement), awaiting the step's cue settlement and the workspace refresh — a cue that failed throws there. The controller extends Neo.controller.Component → core.Base#timeout(time, {signal}) is the destroy-safe wait (registered-async contract; rejects with Neo.isDestroyed).
- The tour receipt (
getTourReceipt) is the cue clock the take's QA reads, so a hold must appear in it.
test/playwright/unit/apps/workstation/TourController.spec.mjs pins the gate contract; the film witness asserts both gates continued: 'auto' under autoGates and that no gate ever waited for a viewer. The controller's own progress path (onTourStepSettled) awaits a 90 ms timeout per step, so a wall-clock floor on a whole playback cannot prove that a hold ran (Sophie's falsifier on PR #19572, review 5480839549).
- The scratch take runner (host-local, never committed) sets
{autoGates: true} through the Neural Link before pressing Start.
The Fix
In TourController: a plain field takeHoldMs (default null; {'<sceneId>[<stepIndex>]': ms}, a startTour/runVisibleTour option like autoGates). In settleTourStep, after the step's own barrier (cue settlement + refresh, unchanged), under autoGates only: the hold the key names → this.timeout(hold), recorded as {heldMs, sceneId, stepIndex} in holds, which the tour receipt carries (reset per playback). No prompt is published, so no Continue button renders; the viewer's tour (no autoGates) reads no hold. The take runner passes takeHoldMs: {'film-journey[0]': <intro>, 'film-reintegration[2]': <return>}; the headless witnesses pass nothing and keep their receipts unchanged. (The first cut of this ticket keyed the hold to gate ordinals; Emmy's return interval showed the hold belongs to any named step — corrected in place, 21:0xZ.)
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
TourController.takeHoldMs — plain field; startTour(script, {takeHoldMs}) / runVisibleTour(script, {takeHoldMs}) option; settable directly before Start by a transport (setProperties over the Neural Link) |
apps/workstation/view/TourController.mjs (takeHoldMs, runVisibleTour, settleTourStep) |
Shape {'<sceneId>[<stepIndex>]': ms}; a key names a step by the screenplay's scene id and the step's index within that scene. A finite ms > 0 holds; 0, negative or missing keys hold nothing. The option omitted keeps the current map (same contract as autoGates); null clears every hold. Read only while autoGates is true: viewer mode (autoGates: false) never holds, whatever the map holds. |
Default null: no hold anywhere — the in-app tour and the headless witnesses |
The field's and both methods' JSDoc |
TourController.spec: the take-hold test (the hold blocks the next beat until release; viewer mode records none; the map survives into the next playback); WorkstationFilmTourNL 3/3 + WorkstationHumanPopupOverlapNL 5/5 with the default |
| The hold's place in the settlement barrier |
settleTourStep |
The hold runs AFTER the step's own barrier: a failed cue still throws before any hold (cueFailed), the workspace refresh still settles first; the wait is core.Base#timeout, so destroying or cancelling the controller mid-hold rejects with Neo.isDestroyed and the existing cancel/cleanup path runs — a hold can never mask a failure or outlive its owner |
n/a (no hold → the barrier is unchanged) |
settleTourStep JSDoc + inline note |
TourController.spec: "a failed window cue fails its beat…", the cancellation tests (unchanged), the take-hold test's stubbed timeout |
getTourReceipt().holds |
TourController receipt assembly (runVisibleTour) |
[{heldMs, sceneId, stepIndex}] in settlement order, one entry per hold that completed. heldMs is the REQUESTED (configured) duration, not a measurement — the measured picture time is the take's beat log and the receipt's elapsedMs. Reset to [] at each playback start (runVisibleTour); [] when no hold ran |
[] |
The holds member's JSDoc |
TourController.spec take-hold test (holds equals the configured entries; a second playback records afresh); the next take's receipt (AC-3, #15252) |
Acceptance Criteria
Out of Scope
- The portrait cards, the explanatory overlay and the CTA card (the cut; #15252).
- The recorder's post-roll for the closing lines (take runner only, no app change).
- Any screenplay change: the steps' positions, captions and pauses stay as they are.
Related
#15252 (the film epic; plan of record step 2, comment 6101992812) · #19568 / PR #19570 (the pacing leaf, merged ec9aac12fd) · #19569 (Ada's cross-window scene, lands on top)
Live latest-open sweep: the latest 20 open issues at 20:5xZ, no equivalent (the two newest film leaves are #19568 and #19569). A2A: the latest 12 messages, no claim on a take hold or the introduction shot. MC sweep: "film take footage for the introductions over the born window; gate wait in the take; autoGates resolves at once", 6 results, no prior decision — the take recipe and today's steer only. Own-assignment sweep: 3 open (#15252 parent, #19568, #19382), none overlapping.
Origin Session ID: 458453a1-1751-41de-b0ce-e3738da16039
Retrieval Hint: "film take hold named step takeHoldMs settleTourStep holds introduction shot explanation interval"
Context
The film's A6 narration (#15252) opens with a hook over the window birth and then the team's introductions — the operator's steer of 2026-10-10 20:4xZ (via Emmy): "our self-intros and matching video/voice content are crucial; more content from us is optional". Emmy's measured bindings (20:55Z) put the introduction's picture between the verified birth and the journey (Continue released no earlier than birth + 20.95 s) and ask for a living-room explanatory interval after the native return (M06 + E02 = 8.4 s before the rail's pin). The take at PR #19570's head gives those passages ≈ 1 s each: the committed breath, then the journey's gate resolves at once; the whole-again beat, then the rail's pin.
Design authority: the operator's steer above, within the 2026-09-23 pacing rule (no gesture or pause stretched to fit a line) and the screenplay's own gate contract — "a gate is a wait, not pacing" (
WorkstationFilmTourNLthird test). An introduction or an explanation over the living room is a designed shot with its own content, not a stretched pause; the question is only where its time comes from, and the answer must leave the in-app viewer's tour and the headless witnesses untouched.The Problem
Under
autoGatesthe controller's step settlement returns as soon as the cue and the refresh settle: replays and takes never wait anywhere. The two shots sit exactly at existing steps — the second gate's step (film-journey[0], right after the committed birth) and the whole-again beat (film-reintegration[2]) — but the take cannot hold there. The alternatives are worse: cue-freepauses of 19 s and 8 s in the screenplay break the 1.5 s reading-beat bound thatfiveBeatFilm.specpins and make every in-app viewer wait; a viewer-mode gate in the take renders the Continue button into the recording (TourToolbar'sworkstation-tour-continue, bound totour.gatePrompt) and the return has no gate at all.The Architectural Reality
apps/workstation/view/TourController.mjs:autoGatesis a plain field set throughstartTour({autoGates})/runVisibleTouror directly before Start by a transport;settleTourStep(data)is the host's settlement barrier the runner awaits per step (TourRunner→stepSettlement), awaiting the step's cue settlement and the workspace refresh — a cue that failed throws there. The controller extendsNeo.controller.Component→core.Base#timeout(time, {signal})is the destroy-safe wait (registered-async contract; rejects withNeo.isDestroyed).getTourReceipt) is the cue clock the take's QA reads, so a hold must appear in it.test/playwright/unit/apps/workstation/TourController.spec.mjspins the gate contract; the film witness asserts both gatescontinued: 'auto'under autoGates and that no gate ever waited for a viewer. The controller's own progress path (onTourStepSettled) awaits a 90 mstimeoutper step, so a wall-clock floor on a whole playback cannot prove that a hold ran (Sophie's falsifier on PR #19572, review 5480839549).{autoGates: true}through the Neural Link before pressing Start.The Fix
In
TourController: a plain fieldtakeHoldMs(defaultnull;{'<sceneId>[<stepIndex>]': ms}, astartTour/runVisibleTouroption likeautoGates). InsettleTourStep, after the step's own barrier (cue settlement + refresh, unchanged), underautoGatesonly: the hold the key names →this.timeout(hold), recorded as{heldMs, sceneId, stepIndex}inholds, which the tour receipt carries (reset per playback). No prompt is published, so no Continue button renders; the viewer's tour (noautoGates) reads no hold. The take runner passestakeHoldMs: {'film-journey[0]': <intro>, 'film-reintegration[2]': <return>}; the headless witnesses pass nothing and keep their receipts unchanged. (The first cut of this ticket keyed the hold to gate ordinals; Emmy's return interval showed the hold belongs to any named step — corrected in place, 21:0xZ.)Contract Ledger
TourController.takeHoldMs— plain field;startTour(script, {takeHoldMs})/runVisibleTour(script, {takeHoldMs})option; settable directly before Start by a transport (setPropertiesover the Neural Link)apps/workstation/view/TourController.mjs(takeHoldMs,runVisibleTour,settleTourStep){'<sceneId>[<stepIndex>]': ms}; a key names a step by the screenplay's scene id and the step's index within that scene. A finitems > 0holds;0, negative or missing keys hold nothing. The option omitted keeps the current map (same contract asautoGates);nullclears every hold. Read only whileautoGatesis true: viewer mode (autoGates: false) never holds, whatever the map holds.null: no hold anywhere — the in-app tour and the headless witnessesTourController.spec: the take-hold test (the hold blocks the next beat until release; viewer mode records none; the map survives into the next playback);WorkstationFilmTourNL3/3 +WorkstationHumanPopupOverlapNL5/5 with the defaultsettleTourStepcueFailed), the workspace refresh still settles first; the wait iscore.Base#timeout, so destroying or cancelling the controller mid-hold rejects withNeo.isDestroyedand the existing cancel/cleanup path runs — a hold can never mask a failure or outlive its ownersettleTourStepJSDoc + inline noteTourController.spec: "a failed window cue fails its beat…", the cancellation tests (unchanged), the take-hold test's stubbedtimeoutgetTourReceipt().holdsTourControllerreceipt assembly (runVisibleTour)[{heldMs, sceneId, stepIndex}]in settlement order, one entry per hold that completed.heldMsis the REQUESTED (configured) duration, not a measurement — the measured picture time is the take's beat log and the receipt'selapsedMs. Reset to[]at each playback start (runVisibleTour);[]when no hold ran[]holdsmember's JSDocTourController.spectake-hold test (holdsequals the configured entries; a second playback records afresh); the next take's receipt (AC-3, #15252)Acceptance Criteria
TourController.spec: underautoGateswithtakeHoldMs = {'s[0]': <ms>}the gate still settles synchronously and prompt-free, the next beat cannot start until the hold's wait is released (a controllable hold, isolated from the controller's progress waits — a wait replaced by an already-resolved promise fails the test), the receipt'sholdsrecords{heldMs, sceneId, stepIndex}afresh per playback; withautoGates: falsethe same configuration records no hold.film-journey[0]for the introduction shot andfilm-reintegration[2]for the explanation (Emmy's bindings set the values) and its receipt'sholdsshows both (#15252).Out of Scope
Related
#15252 (the film epic; plan of record step 2, comment 6101992812) · #19568 / PR #19570 (the pacing leaf, merged ec9aac12fd) · #19569 (Ada's cross-window scene, lands on top)
Live latest-open sweep: the latest 20 open issues at 20:5xZ, no equivalent (the two newest film leaves are #19568 and #19569). A2A: the latest 12 messages, no claim on a take hold or the introduction shot. MC sweep: "film take footage for the introductions over the born window; gate wait in the take; autoGates resolves at once", 6 results, no prior decision — the take recipe and today's steer only. Own-assignment sweep: 3 open (#15252 parent, #19568, #19382), none overlapping.
Origin Session ID: 458453a1-1751-41de-b0ce-e3738da16039
Retrieval Hint: "film take hold named step takeHoldMs settleTourStep holds introduction shot explanation interval"