Skip to content

Session protocols — composing runs into a counterbalanced session (#43)

A run manifest describes one continuous recording; a session protocol describes the whole visit — which runs, in what order, and what is manipulated across them (AGENTS.md §2.1). This is the level that used to live in a side document and a hand-typed run_index. It now has a first-class, declared, reproducible form: schemas/session_protocol.schema.json + ssvep.runtime.session.

The contract + resolver landed first; building a protocol and driving a recording session from it in the GUI followed, and has since shipped (#57, #115, #126, #147) — see The GUI.

The model

A SessionProtocol is a template — authored once, instantiated per participant. It contains its run manifests; it never redefines a run's design. Hardware is not part of a run's design: the protocol carries one acquisition block for the whole session (schema 1.7, #484), and every run is recorded on it. To run the same design on different hardware, record another session.

SessionProtocol
├── name, version, seed, description        # description carries the labels-only record of what's manipulated
└── groups[]                                # FIXED sequence — group order is never reordered
    └── Group{ label, order, runs[] }
        └── RunRef{ manifest (the run manifest, embedded), source{run_manifest, fingerprint}, label? }
  • Groups run in authored order. Only runs within a group are reordered. So "resting → 4 counterbalanced SSVEP runs → resting → 4 more" is four groups; resting stays pinned to its slots.
  • Each group declares its own order:
  • fixed — authored order, every participant.
  • counterbalanced — a balanced Williams Latin square over the group's runs (each run preceded by every other equally often — the right within-subject counterbalance where fatigue/adaptation carry over). The participant's arm = numeric part of sub-XXX mod the number of Williams sequences (n for even group size, 2n for odd). Sequential enrolment (901, 902, 903…) cycles the arms and comes out balanced, with no external state.
  • randomized — a per-participant shuffle seeded by (protocol seed, sub-XXX, group label), so two randomized groups reorder independently yet reproducibly.
  • Runs are embedded (schema v1.2, #115). The manifest is copied into the protocol when the run is added, and that copy is what runs. A protocol is therefore one self-contained file — copy it to an acquisition PC and the whole design goes with it — and it can never be broken, or silently changed, by an edit to a file it merely pointed at.
  • source records where the copy came from: the store-relative path and the manifest's fingerprint at embed time. It is provenance only — nothing reads it at record time. It exists so the Build Protocol tab's Check sources… can answer "has the file I built this from moved on?", at design time, where the answer is actionable. The fingerprint is rename-stable (the pre-1.3 experiment→run change doesn't alter it, §BIDS/#42).

Why embedded, and what replaced the drift stop

Before #115 a protocol held a path plus a pinned fingerprint, and resolve_session re-read the file and refused (ProtocolDriftError) if the fingerprint no longer matched. The intent was right — a design change must be deliberate — but the enforcement landed in the wrong place: the failure surfaced at resolve time, i.e. with a participant already in the chair, and it was caused by an edit made days earlier in another tab. It also meant a protocol could be rendered unrecordable by someone tidying run-manifests/. Eight of the seventeen protocols in session-protocols/ were in exactly that state when this was written.

So the check moved rather than disappeared:

before (v1.0/v1.1) now (v1.2)
what runs the file in run-manifests/, re-read every time the copy inside the protocol
edit a run manifest the protocol stops resolving nothing changes; the protocol still runs its copy
when you find out at resolve time, mid-session at design time, via Check sources…
adopting the new version re-pin by hand Check sources… → Re-embed, which bumps the version

Pre-1.2 protocols are unchanged: they still reference, still verify, and still hard-stop on drift. Loading one in the builder offers to embed its runs — never does it silently, because embedding changes the protocol's fingerprint and recordings already made from it carry the old one.

Protocol versions

protocol_version is the schema version (ssvep.io.protocol), distinct from a protocol's own author-set version field (a design change should never happen as a side effect of editing a referenced run manifest — that is what the fingerprint pin catches).

Version What it added
1.0 The base schema: name, seed, groups, run references by path + fingerprint.
1.1 Questionnaires. A protocol without them round-trips byte-identically as 1.0.
1.2 Embedded runs (#115) — see above.
1.3 A contrasts block (#269): pre-declared between-condition comparisons over the protocol's own runs. A protocol with no contrasts block offers none — nothing is contrasted implicitly, and adopting the field does not otherwise change what a protocol does. See Between-condition contrasts.
1.4 Contrasts using design: "between" or "mixed" (#269 stage 2): a contrast can now split on a documented participants.tsv column instead of only pairing two runs of the same participant. A protocol whose contrasts are all "within" uses none of the 1.4 fields (run, between), so it round-trips byte-identically as 1.3 — the teaching protocol among them. See Between-subject contrasts and Mixed designs.
1.5 The participant-facing blocks (#286): data_use (how consent is obtained, which data-use tiers are offered, the minimum, and the exact words the participant reads), safety_screen (statements added to the photosensitivity safety screen) and start_questionnaire (asked once, at the start of the session), plus closed-choice questionnaire items (type: "choice" with options) in the start questionnaire. A protocol with none of them round-trips byte-identically as whatever it already was. From this version on, a build refuses to load a protocol whose version is newer than any it knows, and says that it needs a newer toolbox, rather than dropping what it does not understand and running the rest. See Data use, the safety screen and the start questionnaire.
1.6 study (#396): the id of the study the protocol belongs to, one of the site profile's declared studies. A study is one data root, so this decides where every session run from the protocol is recorded, and it is stamped into each run's and each answer's session_position. A protocol without one round-trips byte-identically as whatever it already was. See Which study a protocol belongs to.
1.7 A top-level acquisition block (#484): the session's hardware, set once for the whole protocol; every run is recorded on it, and an embedded run's own acquisition block is ignored. A protocol without one round-trips byte-identically as whatever it already was. See The model.
1.8 No new protocol field (#530): stamped once any embedded run is an FPVS design whose item order is drawn per session (design.fpvs.realise: "per_session", FPVS §2). The stamp is what makes an older build, which would play the design's sample order to every participant and record it as per-session, refuse the protocol and ask for a newer toolbox. A protocol without such a run round-trips byte-identically as whatever it already was.

Resolving a session

from ssvep.runtime import session as S

proto = S.SessionProtocol.load("session-protocols/example_compare_bands.json")
resolved = S.resolve_session(proto, "sub-105", "ses-001", store_dir="run-manifests")
for r in resolved:
    print(r.run_index, r.group, r.label)   # run_index is DERIVED 1..N, never typed

resolve_session(protocol, sub-XXX, ses-YYY, store_dir):

  1. Refuses a session label that isn't ses- plus letters and digits, or a session label or subject code that reads as a date (ses-20310517, ses-may17, sub-20310517): both are in every file name the session writes, and real dates are never written (ssvep.runtime.dates.session_label_problem / subject_label_problem; DATA_GOVERNANCE.md). The recorder, the questionnaire writer and the impedance log refuse one too, and Lock says why before any of them is reached.
  2. Walks groups in order; within each group derives this participant's ordering from the scheme.
  3. Loads each referenced run manifest and re-fingerprints it. If it no longer matches the pin, it raises ProtocolDriftError — the design a participant would run has silently diverged from the design the protocol was authored against. That is a hard stop, not a warning: a design change must be a deliberate new protocol version. allow_drift=True overrides (and the recording still freezes what actually ran).
  4. Flattens across groups and assigns run_index 1..N.
  5. Returns ResolvedRun descriptors — each with the frozen (canonicalized v1.3) run manifest, its derived run_index, group label, arm, and the position block.
  6. For an FPVS run whose design says design.fpvs.realise: "per_session" (the default for new FPVS designs, #530), draws this session's item order into the frozen manifest, from a seed hashed from (protocol seed, sub-XXX, ses-YYY, group label, run label), and records it in position.fpvs_seed. It is the one place the resolver changes a run's content, and it is pure and deterministic: the same protocol and codes always resolve the same order, so a relock or a resumed session plays what it started with. ResolvedRun.fingerprint stays the design's. Runs without the field (every older FPVS design, and every other paradigm) are frozen exactly as embedded. Details in docs/FPVS.md §2.

The resolver is pure: it reads only the run manifests the protocol references, scans no sourcedata, and never assigns ses-YYY. Two filesystem helpers sit beside it (and are wired to the GUI later), deliberately kept out of the pure function:

  • next_session(out_dir, sub-XXX) — suggests the next ses-NNN by scanning existing session dirs.
  • session_exists(out_dir, sub-XXX, ses-YYY) — whether the session directory exists at all. It has no GUI caller: an existing directory is the normal state of a session being resumed, so it cannot be the overwrite check. What protects a session's files is the foreign-file rule below — whose files sit at the paths this protocol writes.

What lands in the recording

Each recording's *.session.json sidecar gains a session_position block — the single authoritative record that these recordings are one session under one protocol (analysis reads it rather than inferring from filenames; BIDS filenames already carry sub/ses/run):

"session_position": {
  "protocol": "example-compare-bands", "protocol_version": "1",
  "protocol_fingerprint": "…", "subject": "sub-105", "session": "ses-001",
  "group": "ssvep", "order": "counterbalanced", "run_index": 3, "arm": 1, "seed": 7,
  "label": "alpha"
}

RunRecorder(position=resolved_run.position, run_index=resolved_run.run_index, …) stamps it. Every recording has one: recording is protocol-only since #115. Pre-#115 recordings made outside a protocol are stamped session_position: {"ad_hoc": true}, which is why that marker still exists in readers.

The order of conditions inside a run (#31)

A protocol orders runs; a run with several conditions (a factorial sweep, a band comparison) also has an order inside it, and that order is a design fact too. By default a run plays its conditions in design.conditions order, blocked: every block of the first condition, then the next. That confounds condition with time-in-run for every participant, because fatigue, drying gel and impedance drift land on whichever conditions play last.

A run can declare a different order, and when it does, the order is generated at design time and stored in the manifest, never drawn when the run is recorded:

from ssvep.stim import design
swept = design.expand_factorial(base, {"contrast": [0.4, 0.7, 1.0]})
swept.blocks_per_condition = 4
swept = design.declare_trial_order(swept, "blocked_randomized", seed=1234)   # or "counterbalanced"

(scripts/generate_sweep.py --trial-order blocked_randomized --order-seed 1234 does the same from the command line.) This writes design.order ({"scheme", "seed"}) and design.trial_order, the block sequence in play order ([{"condition", "block"}, …]), stamps the manifest 1.5, and so changes its fingerprint: a protocol that embeds the run covers the order like any other design fact. RunPlan plays that sequence and nothing else; the run's seed still shuffles which target each trial inside a block cues, as it always has.

design.order.scheme What plays Needs
fixed (or no design.order) design.conditions order, blocked: the legacy plan, unchanged nothing; declaring it writes nothing
blocked_randomized blocks_per_condition rounds, each a fresh permutation of the conditions drawn from seed an integer seed (drawn and recorded if you give none); at least 2 blocks per condition
counterbalanced round r plays row r of a balanced (Williams) Latin square, so each condition sits at each within-round position equally often and, within each round, follows each other condition equally often blocks_per_condition a multiple of the square's rows (k rows for an even number of conditions k, 2k for odd); no seed

The carryover balance of counterbalanced holds within each round, not across the whole run: where one round ends and the next begins, the transition is not one the square balances, and the same condition can play two blocks back to back (a b | b a for two conditions). This is also not what counterbalanced means for a protocol's group, which gives each participant one Williams row of runs; here every participant plays every row, in turn, inside the one run.

Anything else is refused, as is a declaration the run cannot honour (fewer than two conditions for blocked_randomized or counterbalanced, one block per condition for blocked_randomized, the wrong number of blocks for counterbalanced). A design.trial_order with no design.order is an explicit, hand-written order: it is played as written, but analysis treats it as not randomised, because nothing says how it was chosen. The runtime checks a stored order by its structure (every round a full permutation, or the Williams rows in turn) and refuses one that was edited after it was generated or no longer fits the conditions and blocks. The same check runs at design time (RunSpec.validate), when a session protocol is validated or saved (every embedded run's order), and when a session is resolved for lock, so a stale or hand-edited order is caught while the run is designed or before the session locks, never with a participant in the chair. Declare the order after the conditions and block count are final.

Every participant gets the same order. (An FPVS run's item order is the exception: by default it is drawn per session, docs/FPVS.md §2.) It is generated once, at design time, so what a blocked_randomized order buys is spread within the run, not variation across people. To vary a run's order across participants, build run variants with different seeds and put them in a counterbalanced group.

The sidecar records the order that was played: plan.trial_order carries the scheme, the seed and one row per trial (position, condition, block, block_position, trial, target_id, target_freq_hz); a run stopped early played the first provenance.trials_recorded of them. What analysis does with the order is in docs/CONTRASTS.md (Within-run conditions and their order).

Build run does not offer the order as a control, and re-saving a declared run from the Build run form drops the declaration (it drops design.factors the same way): declare and save sweeps from Python or scripts/generate_sweep.py.

The approval envelope the session was locked under (#161)

session_position also carries an approval block when the session was locked under a site profile's approval envelope (ssvep.runtime.approval.stamp; docs/SITE_PROFILE.md §3a): which approval, by whichever of its id, version, reference, authority and document_sha256 are set. It records no date of the session and no expiry. It is absent when no envelope gated the lock (none declared, or a profile that is not lab), which is itself the statement that none did:

"approval": {"id": "example-0000", "version": "0", "reference": "0000-0000"}

A pilot / development session (its purpose chosen on Set up Session, frozen at lock) adds a status to this block: "pilot — within envelope" or "pilot — outside envelope", for that run. The declaration itself is the sidecar's data-use stamp, data_use.purpose = "pilot" (#286; older recordings carry session_position.pilot instead). Every between-condition contrast reads either and excludes that recording; the BIDS converter mirrors it as session_type pilot in sub-XXX_sessions.tsv. A bench session carries no envelope block at all. See docs/SITE_PROFILE.md §3a, "Piloting before approval".

The stimulus screen's mode is checked at lock, not only after the run (#314)

session_position also carries a refresh_ack block, stamped by the same lock that freezes identity/consent (ssvep.runtime.session.refresh_ack_check / refresh_ack_stamp):

"refresh_ack": {
  "status": "mismatch", "advertised_hz": 144.0, "screen_index": 1, "n_screens": 2,
  "mismatched_runs": [{"run_index": 1, "design_hz": 240.0}], "acknowledged": true
}

The renderer clocks every waveform on the stimulus screen's actual mode, never on a run's design refresh_hz — it is never re-pointed at the design's number (ssvep.stim.renderer.FlickerRenderer,

230), so the mean presented frequency stays exactly what the design declares regardless of which

mode the panel is actually in. What a mismatch costs is frame-exactness: the design's frames-per-cycle arithmetic, and the frame-exactness / photosensitivity checks built on it, were all computed for the design's refresh_hz and describe nothing about a run on a different clock — a square target that was an exact, jitter-free divisor at the design's rate (240/10 = 24 frames/cycle at 240 Hz) can become a non-divisor at the screen's real rate (144/10 = 14.4, alternating 14 and 15 frames every cycle) — precisely the interval jitter flicker.py's module docstring and the renderer's own stderr warning describe, and until now that warning was the only place this surfaced, after the fact. The one case that does shift the presented frequency is a driver that misreports the panel's real rate (Windows reporting 59 for an actual 59.94 Hz mode: the frame count is unchanged, but the frequency scales by 59.94/59, +1.6%) — invisible to this check, which only ever compares two advertised, whole-number rates; the flik-o-meter (photodiode) is what measures that.

At 🔒 Lock, MainWindow compares the stimulus screen this run will actually use — the operator's own Screen choice when one is set (screen_spin.value() >= 0), falling back to ssvep.stim.display.pick_stimulus_screen's "auto, highest refresh" pick only then, exactly as _start_session resolves it — against every resolved run's display.refresh_hz, beyond a 1% tolerance (session.REFRESH_ACK_TOL, the same tolerance the builder/renderer already use for a mode-vs-manifest gap). Screen is frozen at lock (like identity/consent) so the panel this acknowledgement describes cannot change out from under it before a run starts. Three outcomes, status:

  • "match" — locks with no prompt; refresh_ack still records the rate seen.
  • "unknown" — the screen could not be queried (headless/CI, no attached display, or the driver reports no rate), or at least one resolved run has no positive display.refresh_hz to compare at all — schema requires the key, so this is a defensive case, but an unchecked run must never read as "matched" either. Locks with no prompt — this must never block a real session on a query that simply isn't available — and advertised_hz is null in the first case. A lock recorded "unknown" is the cue to check provenance.advertised_refresh_hz (below) after the fact, since nothing else will have.
  • "mismatch" — a modal names both rates and every affected run before anything is frozen. Cancel leaves the session exactly where every other lock refusal does: unlocked, nothing recorded. Acknowledging locks and stamps acknowledged: true alongside which run(s) disagreed and by how much. The renderer's own clock is not changed either way — it always runs the screen's actual mode, so acknowledging is only ever "I know, and I'm recording anyway," never a fix.

This is a lock-time check, separate from provenance.advertised_refresh_hz (#230), which is a per-run measurement taken by the renderer during the recording itself. The two can disagree (the screen's mode can change between locking a session and starting a given run within it), which is exactly why both exist rather than one standing in for the other. Neither times anything: a run's phases end on the clock (#457, docs/ARCHITECTURE.md, "Trial timing"), and a measured refresh more than 1 % from the mode is warned about as the run starts.

During a run: keep the stimulus window in front (#539)

Don't switch apps, open other windows or bring anything forward on the stimulus display while a run is going. A window that takes the display from the stimulus, even for a moment (an app switch, a notification, a dialog), stalls presentation: the frames it should have drawn never reach the screen. Trial phases still last what they ask for, because they end on the clock, and the flicker follows time (#544: each frame shows the waveform at the refresh it lands on), so its phase is not shifted for the rest of the trial; but for the length of the stall the screen holds one frame, which no analysis can undo (in FPVS a held item breaks the stream's rhythm for that moment, and the items it covered are skipped). On a single shared display, do not touch the toolbox window during a run either; use Abort or Esc only to stop.

What the run keeps about it, in its sidecar's provenance:

  • refresh_check: the pre-run refresh measurement against the display's mode rate (mode_hz, measured_hz, deviation_pct), and the warning text when it was more than 1 % apart (the same warning the Run tab shows as the run starts).
  • Per trial, in presentation_timing: the frames the stimulation's on-screen time holds at the mode rate (stim_expected_frames), the refreshes it skipped (stim_dropped_frames), and its longest frame-to-frame interval (stim_max_gap_s), plus the trial's longest interval over all its phases (max_gap_s). A resting run records the same for each block, and an FPVS run for each sequence, with how far it ran past its scheduled span (stretch_s, about 0 since items follow the refresh clock, #544).

What the flags mean. Three different findings are kept apart (ssvep.runtime.frame_qc). A stall: a trial (block, sequence) whose stimulation skipped more than 2 refreshes, or held one frame on screen for more than 3 frame periods, by its recorded frame-to-frame intervals; the usual cause is something taking the display from the stimulus. Missed refreshes and extra frames: the refreshes that elapsed in the stimulation's on-screen time are round(presented seconds x mode rate) (the vblank clock, which the markers sit on exactly), and the deficit is frames drawn - refreshes elapsed, signed. A deficit below -1 means refreshes went by without a new frame (frames repeated: the loop did not keep up). A deficit above +1 means more frames were drawn than refreshes elapsed, so presentation is not locked to vsync (variable or dynamic refresh (VRR), or a non-blocking, windowed or composited buffer swap), not an app switch. The measured refresh is not used for any of this. A trial that stalled is reported as a stall only. Each is flagged three times: on the Run tab when the run ends (kept in the run's summary), as a Presentation timing section in the run's report, and as a tag on the session's page in the derivatives index; a run whose refresh measured off its mode is flagged the same way. Nothing is excluded: every flagged trial stays in every analysis, and the report says which ones and why, so whoever analyses the run decides. Runs recorded before this have no frame-interval data, so they can show only missed or extra frames (the same count test), never a stall.

The carrier on the participant is confirmed at lock (#195)

The software cannot see what is on the head, so a wrong carrier records without an error and labels every channel wrongly. At 🔒 Lock, a dialog draws the session carrier's montage (the drawing the QA-check pane uses) and asks the operator to choose, from a list, the carrier on the participant. Nothing is selected when it opens, and Confirm and lock stays disabled until the choice is the session's carrier; choosing another says so and points back to Set up Session. It is a prompt, never a lock-out: Cancel or Esc leaves the session unlocked, so the headset can be corrected and Lock tried again, and the abort paths are untouched. It is asked on every lock. session_position gets a carrier_confirm block, in every run's sidecar:

"carrier_confirm": {
  "format": 1, "confirmed": true, "carrier": "Occipital 10-10", "carrier_hash": "0c04e7ff5408",
  "differs_from_protocol": false, "operator": "op1", "at": "1913-04-02T10:15:00+00:00"
}

carrier / carrier_hash name the as-run carrier that labels the recording; operator is the operator id provenance already records (the typed name, on a profile with no whitelist); at is date-shifted. Unlocking drops the block, so the next lock asks again. While the session is locked, Set up Session's hardware identity is read-only: amplifier, sampling rate, headset / electrodes (and Edit carriers…), skin prep and the synthetic-amp switch, each with the tooltip "Locked — unlock the session to change hardware". The connection target, Resolve connection and the acquisition note stay editable (a replugged dongle changes the port, not the hardware). Unlock re-enables them, and a substantive change then goes through the relock prompt. Start still compares the live hardware with what was locked and, if it somehow differs, refuses with a message rather than asking again. A reader can still check carrier_confirm.carrier_hash against acquisition.carrier_hash in the same sidecar.

Unlocking and locking again: continue the session or start a new one (#490)

A session this window already locked can be unlocked and locked again (to correct a field, or after an abort). What happens at the second Lock depends on what changed since the first, and on whether the session already holds recordings:

What changed since the lock At Lock
Nothing, or only an abort and retry The session is kept, silently.
Nothing recorded under the session yet Kept, silently, whatever changed: there is nothing to mix with.
Hardware only (amplifier, sampling rate, headset or carrier, skin prep) Kept, without asking: another cap or amplifier does not make another visit. Every later run's session_position carries a session_continuation block with kind: "hardware_changed" and changes naming what differs from the lock the earlier runs were recorded under (hardware: headset A -> B), with prior_recordings, the operator and a date-shifted at. The runs' own acquisition blocks and carrier_confirm carry the hardware itself.
The protocol (another file, a new version, or a new fingerprint), or the purpose or data-use option A question, before anything runs: "ses-004 already has 2 recording(s) under P v1. Continue ses-004 or start ses-005?" Continue keeps the session and stamps session_continuation with kind: "continued" and the changes; Start ses-005 moves Session to the next free number for the same participant, resets the Purpose and data-use form (a new session is decided afresh, #491) and stops without locking: "ses-005 is a new session: choose its purpose and lock again". Cancel leaves the session unlocked, as it was. When the protocol differs, Continue is not offered: a session folder holds one protocol's data (#279), so only a new session or Cancel.
The subject No question. Another participant's session is never a continuation: the subject dialog (#141) asks new / resume / cancel as before, and the Purpose and data-use choices are cleared (docs/DATA_GOVERNANCE.md), so a data-use stamp cannot travel with a continue across participants.

Loading a different protocol (before any lock). Under a Subject and Session that are set, loading a different protocol asks a question; the Purpose is never reset silently, and never carried to another protocol silently (#491). What it offers follows what the session already holds, not whether its folder exists, using the same rule as the table above: if the session holds another protocol's data (a recording, or any file the lock's #279 check counts as another protocol's), it cannot be continued under this one, so the question says so and offers only Start ses-005 or Cancel, which puts the earlier protocol back (or no protocol, after a Clear) and leaves Session and the choices as they were. Otherwise it asks "Start a new session (ses-005) for this protocol?": Start moves Session on and clears the Purpose and data-use choices (a new session is decided afresh); Keep continues the same session with the new protocol. Keep keeps the choices only while the new protocol's data_use block is identical to the one they were made under. Under other wording (any difference in the block, the option text or the minimum) they are cleared, with a message, because the participant's option was given against the earlier wording and the lock would stamp the new wording's hash. Nothing is asked when the session holds no data and no purpose is chosen. The next session is the next free number after the highest on disk for that participant, and never at or below the Session as typed (Session ses-005 with only ses-001 on disk moves on to ses-006). A session whose recordings were deleted, or a code retired by #506, holds nothing, so it is treated as holding nothing. "Different" means another file path or another protocol name, not another fingerprint: the same file loaded again, or overwritten at the same path under the same name (#481), is the same protocol here and never asks, even if its runs changed (the lock's own checks judge its content); only a change to its data-use wording clears the choices, with a message. Clear protocol does not forget which protocol was loaded last, so Clear and then a different protocol still asks. This supersedes the earlier rule that loading a different protocol silently counted as a new session and cleared the data-use choices.

The comparison uses the data-use decision that would actually be locked, after the cap an earlier, lower option on disk puts on it (#286 P3c): a choice capped back to the recorded option is not a change, and is not asked about again. After a Continue, an abort and retry with nothing changed keeps the session_continuation block, so every later run carries it. A carrier replaced at Start (above) becomes the hardware baseline, so a later relock does not report it as a change.

The checklist's confirmed re-run (overwrite) stays as the last guard, and a session locked by an earlier window is still checked as a taken session (#455). Unlocking drops the session_continuation block from the panel, and the next lock decides it afresh. A relock with nothing changed since a Continue issues it again, so every later run still carries it. A lowering of the data-use option becomes the new baseline, so relocking after it is not asked about the change the lowering made.

The session's flicker risk is shown, and a high one acknowledged, at lock (#286 P5)

At 🔒 Lock, every resolved run is graded the way Build run grades it (ssvep.runtime.flicker_risk.session_risk, the same photosensitivity_risk call through builder.target_photosensitivity), and session_position gets a flicker_ack block with the session's highest grade and the factors that raised it:

"flicker_ack": {
  "format": 1, "grade": "high", "factors": ["high_contrast", "luminance_presumed"],
  "required": true, "acknowledged": true, "operator": "op1", "at": "1913-04-02T10:15:00+00:00"
}
  • A high grade, with a human present (any purpose but bench), opens a modal naming the grade, its factors and the runs at it. Cancel leaves the session unlocked, like every other lock step. Acknowledging locks and records operator (the operator id provenance already records, never a name) and at (date-shifted, like every time the toolbox writes).
  • Any other grade, or a bench session, locks with no prompt, and the block records the grade as shown with required: false. A bench session has nobody in the chair, so it is asked nothing, the same reasoning that leaves it outside the approval envelope.
  • No run that flickers (a resting-only session): no block.

The locked-session status line repeats the grade. Nothing here refuses: the approval envelope above is the only thing that refuses a stimulus. Unlocking drops the block, so the next lock asks again.

Each run's sidecar also carries its own grade, at the top level as flicker_risk, taken from the manifest that sidecar embeds (the as-run one, so it is the grade of what that manifest declares was presented; the display geometry is unverifiable), with rule (the grading rule's version): the run's grade, its factors, and every target's id, freq_hz, presented_hz (twice freq_hz for a contrast reversal), grade, factors, and the inputs the grade could not use (unknown: area, stripes). It sits beside the data-use stamp, never inside it, and both blocks carry format: 1; docs/DATA_GOVERNANCE.md says why.

A session label another protocol already used (#279)

Progress is derived from disk, and the paths it looks at are predicted from sub/ses/task/run alone. So a second protocol that reuses a participant code predicts the first one's files: its symptom checkpoints always (the built-in instruments use the fixed tasks postcondition and endofsession), and its runs whenever the two share a run task label (ssvep is the Build run default, and what nearly every shipped run manifest uses). Until #279 those files were counted by existence: the second study's break-time symptom checks and end-of-session questionnaire read as already answered and were never presented, its checklist could open with runs "done" or read Session complete with nothing recorded, save_response wrote over the first study's answers, and a confirmed re-run truncated the first study's recording in place.

The rule: a file is read before it is counted. A run sidecar or _beh.json counts for this protocol only if its own session_position carries this protocol's fingerprint (not its name: the builder allows two protocols with one name and version, and a v2 saved under the old name is a different design). Anything else at that path is foreign (session.STATE_FOREIGN, provenance.foreign_file):

  • It is never done and never partial, so it is never offered for a confirmed overwrite. The checklist shows ✗ holds «X»'s data, and does not step over it: that row is where the session stops, and the button says Blocked and explains when pressed. Foreign outranks skipped — switching questionnaires off does not make another protocol's answers this session's.
  • It is a lock blocker that names the protocol: "sub-XXX/ses-YYY already holds data from protocol «X» … Choose a different session label". Suggest ses # gives the next unused one. The rule does not depend on consent; an observer-only session is refused the label too, since it still holds someone else's data and choosing another costs nothing.
  • It is never written over, whatever was confirmed. RunRecorder.check_output_clear(overwrite=…) raises ForeignRunError before the amp is touched; if the clash only shows at save time, the run is written to a .rescued-NN sibling as for any clash, and the other protocol's file is untouched. save_response raises ForeignFileError and writes nothing (the operator is shown the answers on any save failure, and the flag acknowledgement still happens).
  • The checkpoint after a run is the one placed after that run (owed_checkpoint_after, selected by its own after_run_index), not "the next item on the whole checklist". The global walk stops at the first pending or foreign item anywhere, so a foreign item earlier in the session used to make the checkpoint actually due read as not-next: neither administered nor announced, or announced for the wrong checkpoint. A due checkpoint that is itself foreign is not administered, and the operator is told at the moment it was due that it has not been asked.
  • A run saved beside a clash happened. When the clash only shows at save time (.rescued-NN), the participant has sat through the run. The dialog says so, says the symptom checkpoint after it has not been asked, and offers to ask it now if it can be saved safely, before any re-run (a re-run is a second flicker exposure). The end-of-run notes are then asked and written to the rescued sidecar. Rescued files are not pushed (push.MODALITY_PATTERNS does not match *.rescued-NN.*) and not converted to BIDS (bids.discover_runs globs *_eeg.xdf) until someone decides which recording to keep. A consent-filtered export does copy them, with the rest of the session's sourcedata folder. Rescue names never repeat: a slot is free only if neither of its files exists, and past rescued-99 the number just gets longer, so a collected run is never lost for want of a name.

Files that name no protocol are foreign too (fail-closed). That covers a sidecar with no session_position, one stamped {"ad_hoc": true}, one whose position has no fingerprint, one that cannot be read, and an XDF whose sidecar is missing. None of them can show it belongs to this protocol, and counting one as done is exactly the failure above. The cost: a session label holding only pre-protocol pilot data cannot be resumed under a protocol — choose another. Every sidecar written since #43 carries a fingerprint, and the only files without one are pilot recordings from before #115 removed the ad-hoc path, which are disposable. The opposite choice (legacy files count) would leave the silent-skip open for any file that happens to lack a position, to save a relabel nobody needs. (A sidecar re-saved by hand with a byte-order mark is still read, not treated as unreadable.)

An XDF with no sidecar is most often this session's own failed save (a crash between writing the XDF and its sidecar), not another study's. It is still not counted, and the checklist, the lock and the recorder all treat it the same way, as ✗ can't tell whose file this is. Before, the checklist offered it as pending and the recorder then refused the run. Every message about it says what to do: if it is a failed save from this session, rename it (for example add .orphan) or move it out of the folder, then Resolve / refresh, and the run can be recorded again. A renamed file is not pushed or converted. It is deliberately not "delete it": nobody has looked at it yet.

One session folder holds one protocol. The rule above only looks at the paths this protocol would itself predict — its own run and questionnaire task labels. A protocol that used entirely different task labels for both leaves files this protocol never predicts and the checklist never sees, so a reused participant code could still lock with someone else's data sitting one directory over. session.session_dir_foreign_files closes that: at lock, and again right before every run or checkpoint starts (a second GUI instance locking the same label, or a sync dropping another protocol's files in after lock, is otherwise invisible until the folder reaches BIDS conversion), every *.session.json, *_beh.json and unpaired .xdf anywhere under sourcedata/sub-XXX/ses-YYY/ — not only the paths this protocol writes, and not the stimuli/ subtree, which holds copied pictures rather than sidecars — is read and judged by the same fingerprint rule. The lock blocker names both what it found at its own predicted paths and what it found elsewhere in the folder. The way forward is usually the same: a different session label. When what it found elsewhere is another protocol's files (including its own rescued ones), the message adds a second option: move those files out of this session folder — never delete them — then lock again. A visit that genuinely runs two protocols on one participant uses two session labels, never one folder for both. Resuming this protocol's own session — including one holding files it rescued earlier under this same fingerprint — is unaffected: those are its own, not foreign.

What it does not cover: two studies running the same protocol (same fingerprint — a teaching protocol run by two sections, a pilot and a main study) that reuse a participant code. Their files are indistinguishable at the protocol level; the study level below keeps them apart instead, by giving each study its own data root.

Which study a protocol belongs to (#396)

A study is one BIDS dataset root, above the protocol (AGENTS.md §2.1). The site profile declares the studies an install records, each with an id and its own data root (docs/SITE_PROFILE.md §10a), and a protocol names the one it belongs to in study (schema 1.6, the Study field on Build Protocol). That is the only place the link is made: the protocol is the design, so which dataset it feeds is a design fact, covered by its fingerprint. Moving a protocol to another study is a design change; so is turning a pilot protocol into the main study's, which keeps the pilot's data out of the main study's analyses.

  • Set up Session follows the protocol. Loading a protocol of a declared study points Output dir at that study's root, and the lock refuses any other root, naming the right one. A protocol that names no study cannot be locked on an install that declares studies, and one that names a study the profile does not declare cannot be locked anywhere.
  • Every run and every questionnaire answer is stamped with the study (session_position.study), beside the protocol's name and fingerprint. The first lock into a declared study's root writes a marker there (.ssvep-study.json) naming the study, so the tree says which study it holds on any machine, including one with a different profile.
  • One root holds one study. Recording, conversion, analysis, contrasts, export and push all refuse a root holding files of another study (or, in a root that belongs to no study, files of more than one), and name the files. That is what keeps two studies' sub/ses codes, analyses and symptom checkpoints apart: the same person in two studies gets a new code in each, in each study's own root.
  • No studies declared is one implicit study, exactly as before: protocols name none, nothing is stamped and no marker is written.

A run with image targets also saves the pictures it showed beside it, and its sidecar lists them in stimulus_files; docs/BIDS.md has the layout.

Pictures travel with the protocol (#258)

A protocol carries its runs' pictures in one bundle beside the file, <protocol-stem>.assets/, at the relative path each image target names. That is where the Run tab, the renderer and the recorder read them from.

  • Save on Build Protocol copies each picture into that bundle. It takes them from the bundle of the protocol file it was loaded from, or from the bundle beside the run manifest a run was added from. Only a file that hashes to the run's pin is copied, and a different file already in the bundle is never overwritten.
  • All or nothing. If any picture cannot be bundled, Save refuses and writes nothing, neither pictures nor protocol, and names each one.
  • One path, one picture. The bundle is flat, so two runs that name the same path (say keys/icon.png) with different pictures cannot both be satisfied. Validate protocol and Save refuse that, and so does the lock for a protocol made by hand. The fix is to rename one of them in its run's bundle. The same path with the same pin is one file, stored once.
  • Check sources… reports pictures that are missing from the bundle or are not the pinned file, beside drifted runs. It can bundle the ones it finds on request. That does not change the protocol file or its version, because the design is unchanged.
  • Build run's Save does the same for a single run manifest: it copies the design's pictures into <stem>.assets/ beside the file it writes.

Picking and previewing a picture in Build run (#259)

A target row's pattern cell is a dropdown (solid/checkerboard/grating) plus a picture button. The button opens a dialog to choose a file, preview it, and set its fit (contain/cover/stretch); accepting re-encodes the chosen file to a clean PNG (see below, #355) and stages that into the design's own bundle (a scratch temp directory until the design is first saved, then the same <stem>.assets/ Save always bundled into), filling in the picture's pinned facts — sha256, pixel size, alpha, mean luminance, RMS contrast — with ssvep.stim.images.describe_bytes measured from the clean bytes, never the original file's. The table cell then shows a real thumbnail, and the layout preview draws the actual picture (fitted, clipped to the target's shape, composited over the real background) instead of a plain rect. An image target loaded from a manifest and never touched still saves back byte-for-byte — only picking a new file or changing fit writes anything, and an already-bundled picture is never re-encoded or re-pinned.

Why a picture is re-encoded before it is bundled (#355). A photograph straight off a phone or camera almost always carries EXIF (capture date, GPS, the camera's make and model, sometimes a thumbnail) and often an embedded colour profile — and Consent-aware export's own checker refuses to ship a picture carrying either, naming what it found rather than stripping it. Left alone, that means nearly every real picture a design bundles can be shown and recorded but never exported: the only fix would be editing the file by hand outside the toolbox, bundling it again under a new hash, and re-recording. So the builder strips at the one point the hash is still unpinned — a freshly chosen file, before it is bundled at all:

  • The picture is decoded, its EXIF orientation is applied (so it is not left showing sideways once the tag that said so is gone), and it is re-saved as PNG with no EXIF, no embedded colour profile, and no PNG text chunk. The pixels, the colour mode (RGB/RGBA/greyscale/palette) and the alpha channel are kept exactly — only the metadata is dropped.
  • The toolbox is not colour-managed: it never applied the embedded profile to begin with, so dropping it changes nothing a participant sees on screen.
  • A photograph typically grows once re-encoded (PNG's lossless coding is bigger than JPEG's lossy one) — accepted, and the only way a picture that actually carries EXIF or a profile can ever clear the export checker at all.
  • An animated picture (an animated GIF or WebP, or a multi-frame PNG) is refused outright at this same point, naming why: the renderer only ever draws a target's first frame, so bundling one would silently cut its own later frames rather than failing where the operator can see it.
  • A picture already bundled keeps its hash — manifests are durable contracts, and only a freshly picked file is re-encoded, never one already pinned by an earlier Save.

The tabs are independent (#115), and Run Session is modal once locked (#126)

Build run, Build Protocol, Set up Session and Run Session hand off through saved files and nothing else. No tab arms another:

tab what it does how its work gets out
Build run build one run manifest; test it with Preview flicker / Preview session save it into run-manifests/
Build Protocol compose saved run manifests into a protocol; Set up acquisition hardware for all of them at once save it into session-protocols/
Set up Session de-identified subject/session/operator; the session's Purpose (and the participant's option, or the pilot attestation); Load protocol… / Resolve / refresh / Suggest ses #; the as-run acquisition fields (amplifier/sampling rate/headset/skin prep); 🔒 Lock protocol and session details nothing — the loaded/resolved/locked state is read by Run Session's getters
Run Session the checklist, Prep selected run, QA check, live views the recording

This was not true before #115. The builder pushed whatever it loaded or saved into the record panel, so opening a protocol to look at it armed it for recording, labelled "from Build Session" — which is how a protocol ends up loaded that nobody loaded. And the Lock button read the Build run form, so it warned about "unsaved changes to the run manifest" on a form the operator had never touched (that tab seeds an unsaved example on startup).

There is no ad-hoc single-run path. Every recording comes from a saved, versioned protocol, so every recording has a fingerprint pin and a place in a session. A one-off is a one-run protocol.

Loading, resolving and locking a protocol live on Set up Session; recording from it lives on Run Session. They moved from Set up Session to Run Session in #126, and #147 moved the load/resolve/ lock half back — next to the identity/consent fields a lock actually freezes, which is where they had been asked to go in the first place. ssvep.ui.session_record.SessionRecordPanel is the one piece of state behind both halves (_proto/_resolved/_presented), exposed as two sub-widgets — protocol_widget (Set up Session) and recording_widget (Run Session) — so there is exactly one loaded protocol and one resolved session no matter which tab last touched it, not two copies that could disagree. See The GUI below for the current shape and why.

A protocol file saved after it was loaded (#481). The loaded protocol is an in-memory copy. Resolve / refresh and Lock re-read the file and compare its content fingerprint (not its mtime) with the one loaded. If it differs, the message names the move ("p was saved since you loaded it: v1 → v2, 2 → 3 runs") and offers Reload, the same load as Load protocol... (so the Set up Session hardware prefill and the #503 data-use retention rules apply to the new file). Never silent; Keep leaves the loaded copy and refresh still resolves it. Lock is refused while the copy is stale (either answer), and a locked session never switches or prompts. A file that is gone or unreadable is reported (at Resolve and again at Lock, as a notice) and does not block: the loaded copy is complete and its fingerprint is what recordings are stamped with.

Authoring a protocol — the Build Protocol tab

The app has a Build Protocol tab beside Build run (the run-manifest form) — two flat, top-level tabs, both from ssvep.ui.design (#57): compose groups from the run manifests in run-manifests/, pick each group's ordering from a dropdown (fixed / counterbalanced / randomized), reorder groups and runs, and save into session-protocols/. Adding a run embeds it on the spot. A live Preview resolution panel shows how the protocol resolves for sample participants (sub-101…sub-104) so the counterbalancing is visible before you save.

The tab was called Build Session until #115. It builds a protocol; a session is one participant's visit under one — and "Set up Session", where a session actually is set up, was the very next tab. AGENTS.md §2.1 locks these words as non-interchangeable.

Check sources… compares every run against the file it was embedded from and reports what has changed, what is missing, and what is still a pre-1.2 reference. Nothing is altered unless you accept Re-embed, which bumps the design version with it — recordings name a protocol by name + version, so two designs answering to both cannot be told apart by reading one. The tree's third column carries the same verdict per run (✓ matches source / ⚠ SOURCE CHANGED / …) and re-checks on every refresh.

Set up acquisition hardware (#121, renamed and rescoped in #147, made session-level in #484) sets the session's acquisition hardware — amplifier, sampling rate, headset / electrodes, skin prep — once, as the protocol's own top-level acquisition block. It opens the shared form (ssvep.ui.acquisition_form.AcquisitionFormMixin / AcquisitionDialog), prefilled from the protocol's hardware, and on confirm writes that block (SessionProtocolBuilder.apply_acquisition_to_all), removes any block a run still carries from an older run manifest, and bumps the version, same as any other Build Protocol edit. A run added afterwards is recorded on the same hardware: the resolver replaces any block a run carries with the session's (ssvep.runtime.session.with_session_hardware), and the preview's first line says how many runs carry one that is ignored.

Why session-level (#484). Until 1.7 this action wrote the hardware into each run already in the protocol. A run added afterwards kept its own default block, and everything hardware-derived read the current run's block: the impedance montage and colour zones, the preview and the recorded channel labels. On 2026-10-11 two pilot recordings were labelled with the default 10-10 sites while a saved carrier was on the head.

It was called Set acquisition for all runs… and its help said it set "the acquisition block" — a word this toolbox spends §2.1 reserving for a block of trials. A control that teaches the operator the wrong vocabulary for the thing they are about to record is worth renaming.

This is the only place hardware is chosen at design time. Until #147 the Build run tab had its own Acquisition group, so hardware was a property of each run design and a protocol could vary it across runs. That is no longer the model, for the reason the bench keeps supplying: hardware does not change within a session. Comparing a Cyton against an actiCHamp means recording the same protocol twice, in two sessions, with the amplifier recorded per session — not two runs of one session claiming two amplifiers between which nothing was re-capped. So a run manifest as authored carries no acquisition block at all (it is optional in the schema since #147), the protocol carries the session's, and the session may correct it with what was actually on the participant's head. A protocol with no session hardware cannot be locked — lock_blocker() says so and points back here.

Older protocols (before 1.7) have no block of their own. They still load. When every run that carries a block carries the same one, that block is used for the session. Loading such a protocol does not rewrite it; Set up acquisition hardware, prefilled with that block, writes it as the protocol's own. When the runs disagree, Build Protocol says which runs carry which hardware and offers to set it, and Set up Session refuses to lock until it is set. Every recording still carries its own acquisition block in its sidecar and its XDF channel labels — the as-run hardware, from the session — as it must for the recording to describe itself. Run fingerprints are unchanged (a pin and a recorded fingerprint must stay readable); "is this the same design?" (design_fingerprint) and the contrast's presented-design fingerprint leave acquisition out.

Known, accepted consequence. Every run this action touches now differs from the run-manifest file it was originally embedded from — that override is the point — so Check sources… will report it as ⚠ SOURCE CHANGED, permanently, for as long as the protocol exists (re-embedding from source would discard the override you just made, which is not what "fixing" this means). This is expected, not drift to chase down or a bug to fix.

Validate protocol… is schema-validate() plus one check Check sources doesn't do: it queries any runs sharing a run.name or run.description. Distinct runs are supposed to say what they are — sharing either is a likely copy-paste that was never edited for the run it landed on, exactly what happened in bench testing: a protocol's three 31–39 Hz runs and three 46–54 Hz runs all carried the description "9-class high-frequency SSVEP, 36–44 Hz operating band", inherited from whichever run they were cloned from and never touched again — a fact a human reading the saved protocol would have had no way to notice. Save is disabled until Validate has passed against the protocol exactly as it currently stands — any edit re-locks it, so this is not a one-time gate you can satisfy once and then ignore for the rest of the session. A duplicate does not block saving outright (a repeat can be intentional); it puts the collision in front of you and asks you to continue deliberately, the same "report + let the human decide" shape as Check sources.

SessionProtocol.duplicate_run_labels(store_dir) is the pure model-level check behind the button, in ssvep.runtime.session next to source_report.

Data use & consent… (#548) edits the protocol's research data-use wording, its data_use block: see Data use, the safety screen and the start questionnaire.

…or in Python

Equivalently (and what the example script does), build one in Python (see scripts/build_example_session_protocol.py):

from ssvep.runtime import session as S
store = "run-manifests"
proto = S.SessionProtocol(name="compare-bands", version="1", seed=7, groups=[
    S.Group("rest-pre", S.ORDER_FIXED,        [S.RunRef.pin("resting_eo_ec.json", store)]),
    S.Group("ssvep",    S.ORDER_COUNTERBALANCED, [S.RunRef.pin(f, store, label=l) for f, l in RUNS]),
])
proto.validate()
proto.save("session-protocols/compare_bands.json")

RunRef.pin loads the run manifest, embeds it, and records where it came from. Note the resolver call above passes store_dir only because it also has to handle pre-1.2 protocols — an embedded one resolves with S.resolve_session(proto, "sub-105", "ses-001") and touches no filesystem at all.

A worked one, end to end

Three appearance contrasts over one grid walks a complete shipped protocol — three groups of two runs, generated by scripts/build_teaching_lab_protocol.py — and, more usefully, what each of its manipulations fails to isolate and why. It is also where the display-resolution question is answered: what display.resolution_px does and does not control at run time.

Questionnaires (#20)

A group also carries a break-time symptom checkpoint — the REB-approved post-condition questionnaire, administered after that group's last run — and the protocol carries an end-of-session instrument, asked once at the very end. Both are attached by default when a group is created, both are removable, and both are stored inline in the protocol so it can say exactly what it asked (a change to the questions is a design change, caught by the protocol fingerprint like any other). They resolve into the running order alongside the runs, and the operator's checklist is that order. Full SOP, storage layout, and the flag thresholds: QUESTIONNAIRES.md.

Data use, the safety screen and the start questionnaire (#286)

Three optional protocol blocks hold what a participant reads before anything is recorded. Like the questionnaires, they are stored inline: the words a participant is shown travel with the protocol version that carries them, and changing one word changes the protocol's fingerprint. A research session whose option the participant gives on screen shows safety_screen and data_use (its consent_form too, under on_screen) on the participant screens at lock, and a teaching session shows safety_screen alone (#542); start_questionnaire is not presented yet (#286).

data_use is what a research session asks the participant about their data. The tiers are cumulative: 0 observer only (the participant takes part, and nothing from the session is saved), 1 this study only, 2 this study and future research by this team, 3 share openly (a public repository, any future use).

Key What it holds
consent_mode on_screen (the consent_form is shown, then the tier question), paper (the form stays on paper; the tier question is still asked on screen, so leave tier checkboxes off the paper form), or none (no consent form, only the tier question).
tiers_offered The tiers the participant may choose from, e.g. [0, 1, 2, 3]. A study need not offer all four.
minimum_tier The lowest tier that lets the session be recorded. It must be one of tiers_offered. A choice below it is a decline: the session ends and nothing is written. With a minimum of 0, choosing 0 lets the session run with nothing saved.
statements Paragraphs shown above the options, verbatim.
options One entry per offered tier, keyed "0" to "3": {"text": …}, with an optional short label. Every offered tier needs one, and no other tier may have one.
whichever_you_choose Paragraphs shown after the options, verbatim: what holds whatever the participant chooses.
consent_form The consent form as Markdown. Required for on_screen, refused otherwise.

The schema checks each key's shape. What it cannot express (the minimum is offered, a text for every offered tier, a form for on_screen) is checked by ssvep.runtime.data_use.wording_problems, which every load and save runs, so a protocol that breaks one fails to load just as a schema error does. A protocol's own block is held to one more rule (protocol_wording_problems, #548): it may not be the toolbox's built-in generic wording, unchanged. Declared as the protocol's own, that text would be stamped source: "protocol", a claim that someone approved it for this study, and it would let a lab machine, which refuses research without wording of the protocol's own, run research under the generic text. A protocol uses the built-in wording by having no data_use block, on a machine that allows it, where it is stamped builtin. Load, Save and the lock all refuse such a block. The check is exact: it compares the block's canonical form, the same one the stamp's hash is taken over, so it stops the untouched pre-filled wording (or an exact copy of it) being saved as the protocol's own. A copy with any edit at all is accepted as the protocol's own wording, and the stamp's hash then identifies exactly which words were used. A protocol without a block is unaffected. A protocol file refused on load is not silently dropped from the contrast reports either: the contrasts index and 03_run_contrasts.py list every protocol file they skipped, with the reason.

A protocol without a data_use block declares no wording. What a research session does then depends on the deployment: a lab profile refuses to lock it, and a generic one uses the toolbox's built-in generic wording (all four tiers, minimum 0 so that option 0 is observer only, no consent form) and records that it did, with the wording's hash. The full purpose and tier policy is in Data governance.

Editing it in Build Protocol (#548). The Data use & consent row of the Session protocol box shows what the block declares, and Data use & consent… opens its editor: whether the protocol declares wording of its own at all, the consent mode, which options are offered and the minimum, each option's label and text, the statements shown above the options and the closing paragraphs shown after them (one paragraph each, a blank line between paragraphs), and the consent form, pasted or imported from a local Markdown file (UTF-8) and shown rendered beside its source. The form is kept only under on_screen: under paper or none it is left out of the block, and the editor says so. Ticking "declares its own wording" on a protocol that had none fills in the toolbox's built-in generic wording as a starting point, but OK refuses it until something in it has been changed, because the built-in wording unchanged can't be a protocol's own (see above): edit it to your approved wording, or untick to use the built-in wording where the site allows it. Nothing in it uses AI or the network.

  • The stored format does not change. The editor starts from the block it was given and overwrites only what you edit: an untouched field keeps its stored value exactly, even where the editor displays it slightly differently (a non-breaking space or a Windows line ending in pasted text), a paragraph list is not re-split unless you edited its text, and a key it does not know is carried through rather than dropped. Switching away from on_screen when the block had a consent form asks before OK removes it. Text you paste, type or import does pass through Qt's text editor, which turns a non-breaking space into an ordinary space and normalises line endings, so an imported Markdown file is stored as the editor shows it, not byte for byte: check the stored form, and the wording hash the editor shows, against the approved file if that matters.
  • Problems are shown, not enforced. Below the tabs it lists what Validate and Save would refuse the block for (the schema, then wording_problems), or, when there are none, the wording's SHA-256, which is what research recordings made under it are stamped with. It also says what this machine's purpose and tier rules would do with a research session under it, using the lock's own checks: a teaching machine never reads it, a lab refuses a protocol with none, and an on_screen protocol cannot be locked where the site's tier_entry is operator (see At lock below). OK hands the draft back with one exception, the built-in wording unchanged (above); any other draft with problems is held in the tab, and Validate and Save refuse it exactly as they refuse a hand-written one.
  • A change to the wording is a design change. It changes the protocol's fingerprint, so Save re-locks until Validate is run again, and the version is bumped by the same rule as every other Build Protocol edit: at most once per save, and not at all for a protocol that has never been saved or loaded. OK with nothing changed changes nothing.

A worked example. Every word of wording below is a placeholder, written to show the shape of the block and nothing else. It is not wording any ethics board has approved, and it must not be used as consent wording: put the words your own board approved in its place.

"data_use": {
  "consent_mode": "paper",
  "tiers_offered": [0, 1, 2, 3],
  "minimum_tier": 1,
  "statements": [
    "PLACEHOLDER: a statement that the recording is stored under a code number, never with the participant's name.",
    "PLACEHOLDER: a statement that each option includes the options before it."
  ],
  "options": {
    "0": {"label": "PLACEHOLDER decline", "text": "PLACEHOLDER: what choosing option 0 means under this study's minimum."},
    "1": {"label": "PLACEHOLDER this study only", "text": "PLACEHOLDER: the approved text for option 1."},
    "2": {"label": "PLACEHOLDER future research by this team", "text": "PLACEHOLDER: the approved text for option 2."},
    "3": {"label": "PLACEHOLDER share openly", "text": "PLACEHOLDER: the approved text for option 3, including that public data cannot be withdrawn."}
  },
  "whichever_you_choose": [
    "PLACEHOLDER: what holds whichever option is chosen, e.g. when the option can still be lowered."
  ]
}

Here the form is on paper, all four options are offered and the minimum is 1, so option 0 is a decline: the session does not run and nothing is written. In the editor that is consent mode paper, all four "Offer option" boxes ticked, minimum option 1, and the statements and the closing paragraph typed or pasted one paragraph each. To administer the form on screen instead, choose on_screen and paste or import it on the Consent form tab; the participant then reads it on the stimulus monitor at Lock, which needs a site whose tier_entry is participant.

At lock, who enters the participant's option is the site profile's data_use.tier_entry (Site profile §6b). Its default, participant, has the participant pick their own option on screen: Lock runs the participant screens on the stimulus monitor (the safety screen, the consent_form under on_screen, then the data-use question showing statements, each offered option and whichever_you_choose) and locks only once the participant has chosen, stamped entered_by: "participant". Under operator, Set up Session's Purpose offers the tiers in tiers_offered, each with its label, and the operator enters the option the participant chose, after the participant has been given the protocol's statements, options and whichever_you_choose in the study's own materials. Under operator, a research session under an on_screen protocol cannot be locked: on-screen consent runs only under participant, and a stamp saying mode: "on_screen" would record a procedure that did not happen. The stamp records who entered it: entered_by: "operator", or participant for an answer the participant gave on screen. A choice below minimum_tier refuses the lock: the session does not run and nothing is written. The stamp every file of the session carries names the wording's source (protocol or builtin) and its SHA-256, the tiers offered and the minimum.

safety_screen holds extra_items: statements added after the toolbox's fixed core list, in the first person and lay language ("I have a history of fainting."). A protocol can only add statements. There is no key that removes or rewords a core one. The screen takes a single answer for the whole list, and no per-statement answer is ever recorded.

start_questionnaire is a questionnaire like end_questionnaire, asked once at the start of the session, before electrodes and impedance. The background questionnaire (ssvep.runtime.questionnaire.demographics) is the instrument it is meant for, which is why 1.5 also accepts closed-choice items: type: "choice" with options, each a value (what the beh TSV records, [a-z0-9_+-]) and a label (what the participant reads). A choice item has no text box, so nothing identifying can be typed into it. Choice items are accepted only here: the checkpoint screen that shows a group's questionnaire and the end-of-session one cannot render them, so validation refuses one in either slot rather than letting the session fail at that checkpoint. Validating a protocol also runs each questionnaire's own checks, such as two options of one item with the same value.

The GUI

Both halves have shipped (#57), and running a session is now modal to one tab (#126).

  • ✅ Protocol builder — the Build Protocol tab above (ssvep.ui.session_builder).
  • ✅ "Record from protocol" — split across Set up Session and Run Session (ssvep.ui.session_record), both reusing Set up Session's own Subject / Session / Operator / Purpose / Output-dir fields through getters (a session's participant and data use belong to the visit, not to any one run). Load protocol…, Resolve / refresh, Suggest ses #, the Purpose and 🔒 Lock live on Set up Session, next to the identity/ data-use fields a lock actually freezes — that is SessionRecordPanel.protocol_widget. The checklist and the one Prep selected run button stay on Run Session — SessionRecordPanel.recording_widget — because that is where a run is actually recording and where the always-available Abort and live views live. Both tabs show the same loaded-protocol status line, since both widgets read the same underlying _proto/_resolved state; there is no second copy of it to disagree with the first. Until #126 load/lock lived on Set up Session; #126 moved both to Run Session so that tab — and the QA check that used to be a separate stop on the way — never needed to be visited on their own; #147 moved load/resolve/lock back to Set up Session, because the lock freezes the identity/consent fields that live there, and a lock button that reads fields on a different tab than the one it is on was the wrong split. Run Session keeps the checklist, the live panes and the Start button — everything that only matters once a session is actually recording. A protocol arrives here only via Load protocol… — the Build Protocol tab pushes nothing (#115). Loading resolves for the current Subject on the spot, so the run checklist appears immediately rather than only after Resolve; a drift or unset subject leaves a status line, not a modal. The session appears as a checklist whose progress is read from disk, in three states (session.session_progress): pending (no .session.json sidecar — a crash before saving is correctly offered again), done (the sidecar shows every planned trial recorded), and partial — the sidecar exists but the run was aborted or lost its stream part-way, shown as ⚠ INCOMPLETE (n/N trials) — re-run. A run whose EEG stopped arriving mid-run but still covered at least half of the stimulus counts as done (#483's rule), so it reads ✓ recorded — ⚠ EEG stopped for 42 s: recorded, and flagged so you can decide whether to re-run it. A fourth, foreign, is a sidecar at this run's path that another protocol wrote, or that names no protocol at all, or an XDF with no sidecar — never done, never offered for overwrite, and a lock blocker (#279; see "A session label another protocol already used" above). One button, "Prep selected run" ("Start next run" until #147 — renamed so the label says what it acts on, since the operator can select a different row to redo it; then "Start selected run" until it was renamed again so the label says what it does — it prepares the run, and Start recording on the live-EEG pane is what begins it; #126 replaced the old "Record next run" + "Re-run selected…" pair with it) acts on whatever is highlighted — the checklist auto-highlights the first pending item (deliberately skipping partial ones: something is on disk for those, so replacing it is a decision, not a default) on every refresh, and clicking a different, already-done-or-partial row re-stages that one instead, with the same named, confirmed, reason-logged overwrite "Re-run selected…" used to ask for. Each run is handed to the normal record path via session.recorder_for (frozen manifest + derived run_index + position — never hand-typed). Consent is captured once per session; an observer-only session persists nothing, so it runs forward-only (resume/re-run disabled). ses-YYY prefills via next_session.
  • ✅ QA check folded in (#126) — the impedance check (ImpedanceCheckWidget) that used to be its own QA check tab is now a collapsible pane below the protocol checklist on Run Session. It starts collapsed; pressing Prep selected run expands it (and the operator can expand/collapse it manually any time by clicking its title). Confirm nothing else in the app still expected QA check as a standalone tab before relying on this — it is gone, not hidden.
  • ✅ The Run Session panes actually collapse — all four panes (session checklist, QA check, live EEG, live decode/resting) share one vertical splitter, and each has a ▸/▾ title row that is the toggle (ssvep.ui.run._CollapsiblePane). The checklist joined them last: it was a fixed group box, which made the pane there is nothing left to do with between runs the one pane that could not give its height back. Two things this replaced are worth remembering, because both read as "the pane is gone" to the operator and neither announced itself:
  • The first version used a checkable group box. Unchecking it hid the pane's content but the splitter kept every pixel it had already allocated, so collapsing freed no space — on a laptop, where every pane open does not fit, the one control meant to fix that did nothing. A collapse now also caps the pane's maximum height to its title row, which is what makes the splitter hand the space to the other panes.
  • A pane could be dragged out of existence: pull the divider above it past it and the pane, title row and all, was gone, with nothing on screen saying it still existed and no way back — and the zero height was persisted, so it stayed gone across restarts. That is how the impedance pane "disappeared". The splitter is now setChildrenCollapsible(False): dividers resize panes, only the triangle collapses them, and a stale zero in QSettings is healed on load (MainWindow._restored_pane_sizes).
  • ✅ Unified live-EEG pane (#126) — below the QA-check pane, and carrying the always-available Abort beside Start recording, one _LiveEegPlot (ssvep.ui.run.LiveEegPane) is now used both for the pre-run signal check and for in-run viewing, instead of two separate plot instances (the old QA check tab's SignalPreview, a shorter-window plot of its own, plus Run Session's own recording-time plot). Three buttons at its top: Preview EEG toggles a passive preview stream (nothing recorded — a lesson from early bench testing: you couldn't see the signal until stimulation started); Start recording starts stimulus presentation (what used to be "Start session"), auto-starting the preview first if it wasn't already running so there is always at least a glance of live signal on the way in; and Abort stops a run in flight. Abort used to have a row of its own above the panes so that no collapse could hide it; it sits here now, and the §4 guarantee is kept instead by pinning this pane open for as long as a run is actually recording (MainWindow._pin_eeg_pane_open): clicking the title row mid-run simply does not collapse it. The refusal is deliberately not a disabled title button — a disabled QToolButton draws its text greyed, which would dim this pane's own as-run device/rate line at the one moment it matters, and make the pane read as dead right beside the Abort the pin is protecting. This pane's title row also states the as-run device and sample rate (#168), where a mid-session bench correction cannot be missed on the way to Start recording. The amp is still single-client — starting a recording still releases any in-process preview before the recording bridge subprocess opens the board, exactly as before (MainWindow._release_preview, unchanged logic, re-hosted target). Below the live-EEG pane, the live-output view (live decoding / resting alpha reactivity) is unchanged, just re-hosted in the same place it always was.
  • ✅ Live SNR spectrum (#21) — the pane's right-hand plot has a View selector next to PSD avg: PSD (the long-standing raw-power view) or SNR spectrum, shown one at a time in the same slot. The SNR view is the standard neighbouring-bin SNR (power at each frequency ÷ its neighbours, excluding a small guard band, in dB), computed by the identical kernel the offline report's own SNR-spectrum figure calls (ssvep.classification.spectral.snr_spectrum_db, shared with ssvep.analysis.pipeline.mean_snr_spectrum) — not a second implementation, so a live reading and the report written from the same recording agree bin-for-bin at the same window length; the SNR view's own averaging window is independent of the PSD pane's fixed ≤2 s Welch segments, so their frequency axes only coincide below a 4 s average (the SNR view's bins narrow to 0.25 Hz past that point, the PSD pane's stay ≤0.5 Hz). Dotted markers sit at each target's response frequency and its harmonics, rebuilt from the actual averaging window on every update, so a harmonic the site's configured mains line (snr_safe_harmonics guard, #309/#362) currently rules out is never marked out of step with what the live readout text beside it says. A target whose response frequency is at or past Nyquist draws no marker on either view; the readout text already says so explicitly ("…Hz >Nyquist", #376). The SNR curve recomputes on the same throttle as the PSD curves (PSD_UPDATE_S) and only while it is the one showing, so switching views leaks no new timer — but it is a second Welch pass over the same tail, not free: measured at roughly 3-300 ms beside the PSD loop's own tens of ms to ~2.6 s at the high end (64 ch, up to 5 kHz/60 s), both on the GUI thread.

🔒 Lock protocol and session details

The button on Set up Session, below the protocol/acquisition fields — moved off Set up Session onto Run Session in #126, then back onto Set up Session in #147. It freezes both halves of what a recording is — which design (the loaded protocol) and whose session (Subject / Session / Operator / Consent / Output dir) — so it can be started safely, and it now sits next to every field it actually freezes rather than reading them across a tab boundary. The QA-check pane's montage comes from the first resolved run, so impedance is usable straight after locking.

Until #115 only the first half was locked: subject, session and consent stayed editable underneath a live checklist, so a run could be ordered under one identity and recorded under another with nothing saying so. The acquisition-as-run fields (amplifier / sampling rate / headset / skin prep / note — dropdowns since #147, defaulting to the protocol's hardware) stay open on purpose: they record what was physically on the participant's head and get corrected at the bench, which is the whole reason they exist. Electrode type is no longer among them because it is no longer asked: the headset already states whether the electrodes are dry, gold-cup or active, so #147 derives it (builder.headset_electrode_type) rather than offering a second field that could disagree with the first. It is still recorded, and the live mains threshold and the Cyton-dry impedance zones still read it. Reference and ground are derived the same way (builder.headset_reference_ground): on the free-electrode array they are the white SRB and black BIAS leads on the left and right earlobes — a fact about the montage, not a session choice — so a recording made from a protocol saved before that was settled still gets them right. See Electrode montage. Locking does not disable Build Protocol or Set up Session by itself — an operator can still author the next protocol during a break, or correct the as-run acquisition fields between runs. What does close every other tab off is a run actually recording (see below) — that is deliberately a narrower condition than "locked", so those two uses keep working.

Before the lock the checklist is a preview; Prep selected run is disabled. After it, the protocol can no longer be swapped (Load protocol…, Resolve, Clear protocol are disabled) — unlock to change either half.

If it refuses, it names the missing thing (MainWindow.lock_blocker, pure and unit-tested): no protocol loaded, a protocol that will not resolve for this participant (quoting the reason, e.g. drift on a pre-1.2 protocol), a protocol with no session hardware (none set, or a pre-1.7 protocol whose runs disagree — it names which runs carry which hardware and points back to Build Protocol's "Set up acquisition hardware"), or a missing Subject / Session / Operator. Lock then shows the session's carrier (Set up Session's, the one every run is recorded and labelled as) with its montage drawn, and asks which carrier is physically on the participant (see the next subsection). When that carrier differs from the protocol's, the same dialog says so, naming both: there is one prompt, not two.

Locking also checks the stimulus screen's current mode against every run's design refresh_hz and, on a mismatch, asks for an explicit acknowledgement before freezing anything — see "The stimulus screen's mode is checked at lock, not only after the run" above.

Runs can only be started from the checklist. Pressing Start with nothing prepared is refused, and there is no fallback to fall back to — see §"What a lost run cost us" below (#86).

Tab-switch-mid-run (#124) now has a structural guard, not only a reactive one. For as long as a run is actually recording, MainWindow._set_other_tabs_enabled(False) disables every tab but Run Session outright — a tab-bar click leads nowhere else for that whole window, closing the gap the older reactive guard (_on_tab_changed, reverting a switch after the fact) could only catch after it happened. The reactive guard stays anyway, as defense-in-depth: no traceback has ever been captured for the crash it guards against, and Qt does not block a disabled tab being reached via setCurrentIndex called from code rather than a click. #124 stays open, referenced rather than closed — the root cause of the original crash is still unknown; this hardens the one reachable path (the tab bar) considerably, it does not prove the underlying bug fixed.

Under the panels, the load-bearing logic is pure and unit-tested: session_progress / next_pending (derive-from-disk), recorder_for (resolver → recorder), and run_output_base (the one shared BIDS-stem function, so "where a run's sidecar is" and "where RunRecorder writes" can't drift apart). Only the amp+display record loop itself is hardware-gated.

What a lost run cost us (#86)

Two runs of a bench-test session were recorded outside the protocol that was driving the session, and nothing at the time said so. The chain is worth keeping, because every link looked reasonable alone:

  1. An abort still saves — deliberately; partial data is data. An 11-second abort wrote a complete XDF + sidecar.
  2. Progress was "the sidecar exists", so that 11-second run read as recorded.
  3. _on_session_done cleared the pending protocol recorder on every outcome, abort included.
  4. The Setup tab stayed fully armed — locked manifest, subject, session, Run # — and looked identical.
  5. Start silently built a form recorder: run_index from the spinbox, no position.

An operator worked around a checklist that was telling them something false, using the only affordance left — a hand-typed run number — and that took the run out of its protocol. The recovered run was the right condition at the right index; what it lost was the fingerprint pin and the drift check, which is precisely the guarantee #43 exists to give.

The same investigation surfaced a hazard that had not fired yet: run_output_base maps run_index straight into the filename and save_outputs had no existence check, so the accidental path was also the only path with no overwrite protection. It now refuses (check_output_clear before the amp is touched), and if a clash is somehow only discovered at save time the run is written to a .rescued-NN sibling rather than dropped — refusing an overwrite must never cost a participant's session.

The lesson generalises past this bug: a state the operator can see but the software cannot express will be worked around, and the workaround is where provenance dies.

86 closed step 5 by refusing while a protocol was loaded. That left Clear protocol as a way

back to the same recording, one confirm away — the road was still there, with a gate on it. #115 removed the road: there is no form recorder to fall back to in any state, and a one-off recording is a one-run protocol. It also fixed something #86 did not reach — the as-run acquisition fields (headset / electrode type / prep / note) were threaded through the ad-hoc recorder only, so every run recorded from a protocol, i.e. every real session, silently dropped them. That is the wet/dry electrode-metadata gap reappearing one level up, and removing the ad-hoc path is what made it impossible to leave unfixed (session.recorder_for(..., acquisition_override=…)).

Not yet, and why

  • Structured factors — v1 records the manipulation as labels (group/run labels + description), which satisfies "declared, not inferred". A machine-readable factors block waits for the results-KB consumer (#37) to shape it rather than guessing now.
  • BIDS sessions.tsv surfacing — the protocol/arm is authoritative in the sidecar; surfacing it into the BIDS sessions.tsv/_eeg.json is a later io.bids concern (see docs/BIDS.md).