Skip to content

Site profile — configuring a deployment

A toolbox that names one lab's server, one lab's institution and one country's mains frequency in its source code is a toolbox only that lab can run. Those are facts about a deployment, not about the software, so they live in a git-ignored config/site.local.json describing the machine in front of you. This is the operator SOP for that file: what it holds, how to make one, and what the toolbox does when there isn't one. The code is ssvep.runtime.site; the contract is schemas/site_profile.schema.json; a fully commented template is config/site.example.json. Issue: #165.

1. The default is generic, and that is the point

A clone with no profile behaves generically: no institution stamped into BIDS, no push destination, no pre-filled operator, PowerLineFrequency recorded as n/a. A lab is the configured special case — set up once per acquisition PC — not the built-in assumption.

That inversion is most of the work of being shareable. Before it, the generic path existed only in the sense that someone could edit the constants: it was never the path anybody ran, so nothing kept it working. Now it is what an ordinary pytest -q exercises from end to end (tests/conftest.py pins the suite to the generic profile), and a student cloning the repository on a laptop gets a toolbox that records, converts and analyses without ever being told about a lab it has nothing to do with.

Nothing about safety or governance is relaxed by being generic. The photosensitivity warnings, the always-available abort, the consent gate and de-identification are code, not configuration — see §5.

2. Making one

Copy config/site.example.json to config/site.local.json and edit it. *.local.json is already git-ignored, so a profile never reaches GitHub — which matters, because it names a server and may list the people who operate the machine.

A lab that keeps its machine profiles outside the toolbox checkout — one per machine, in a repository of its own — points the toolbox at one with the SSVEP_SITE_PROFILE environment variable instead (§10). Set, it replaces config/site.local.json outright, for reading and for writing.

Three kinds, all one schema:

profile What it means Typically
generic Anything unconfigured — a student laptop, a fresh clone, a demo machine. Everything blank.
lab A research deployment collecting under an ethics approval. Institution + governance + push destination + operator list.
teaching A classroom machine. No approval, and normally no destination. push.allowed: false, locked: true, a dataset name for the course.

The kind is a label on one schema, not three schemas. It is what the GUI shows and what a person reading the file understands at a glance; every actual restriction is expressed by the fields.

3. What each block does

Block Effect
institution InstitutionName / InstitutionAddress / InstitutionalDepartmentName in every recording's BIDS *_eeg.json. A field left empty is omitted from the sidecar, never written as "n/a": an absent key says the dataset does not record it, "n/a" asserts that somebody looked and there was none.
governance The approval this deployment collects under. Stamped as HowToAcknowledge (and License) on the raw dataset and on derivatives, since a derivative is bound by whatever governs the data it came from. Optional — ethics approval is a property of some deployments, never a precondition for running the toolbox. approval is a list of approval envelopes (§3a), because a lab can run under more than one approved protocol at a time.
dataset Name / Authors / Acknowledgements in BIDS/dataset_description.json and the derivatives dataset. Research-team names only — never a participant.
acquisition.line_frequency_hz Mains frequency here: 60 in North America, 50 across most of the rest of the world. null means unknown and BIDS then records "n/a", which is honest where a hardcoded 60 was one country's fact presented as a universal one. The decoders also use it, live and offline: reference harmonics that would land on the mains line or one of its harmonics are left out, because a candidate that has one matches the hum on every trial and wins them all (#280). There, unknown means both 50 and 60 Hz are guarded, because guarding nothing is the one answer known to be wrong, and guarding a line that is not there costs at most a harmonic order. A configured site guards its own line only.
push The deployment's default destination, and whether pushing is permitted at all (§4).
operators Who may be named as the operator of a recording (§6).
questionnaires Which built-in questionnaires a new session protocol starts with (§6a). Default: none.
data_use tier_entry: who enters a research participant's data-use option, the participant on screen or the operator on their behalf (§6b). Default: participant.
paths designs: extra directories of run manifests and protocols, listed beside the shipped ones. data_root: the BIDS root to record into and analyse, instead of the checkout's BIDS/ (§10). Default: the checkout's own.
studies The studies this install records, each an id and its own data_root (§10a). A protocol names its study, and a study's sessions are recorded, analysed and pushed in its root only. Default: none — one implicit study, rooted at paths.data_root.
publish forbidden_literals: strings this deployment never publishes — its host names, accounts, file numbers — which the leak guards refuse (§11). history_literals / history_patterns: extra tokens only the history scan refuses (§11). Default: none.
locked The GUI will not offer to edit a locked profile. A guardrail so a student does not change the class configuration by accident — not security: anyone with the machine can edit the file.

3a. The approval envelope: an approval's limits, as configuration

The toolbox ships only literature-backed constraints — Fisher et al. and Harding et al. on flicker frequency, colour and pattern density — and those warn; nothing shipped refuses a stimulus. A limit that refuses belongs to one deployment's ethics approval, so it arrives only as that deployment's own configuration: an entry in governance.approval, in the git-ignored profile (#161). It is optional. With no envelope, the toolbox behaves exactly as it does without one: warnings only. The code is ssvep.runtime.approval.

The shape is nested. An entry's top level says which approval this is; its permits block says what it allows:

"approval": [
  {
    "id": "example-0000",
    "title": "Example SSVEP study",
    "authority": "Example University Research Ethics Board",
    "reference": "0000-0000",
    "version": "0",
    "approved_on": "2000-01-01",
    "expires": "2099-12-31",
    "document": "example-approval.pdf",
    "document_sha256": "<64 hexadecimal characters>",
    "permits": {
      "paradigms": ["ssvep", "resting", "fpvs"],
      "flicker_hz": { "min": 5, "max": 95 },
      "colour": { "achromatic_hz": { "min": 5, "max": 35 }, "saturated_red": false },
      "target_size": { "max_deg": 3.5, "band_hz": { "min": 5, "max": 35 } },
      "max_cpd": 2.0,
      "max_cycles_across_width": 4,
      "max_cycles_any_orientation": 6,
      "max_targets": 12,
      "trial_stim_s": { "min": 1, "max": 6 },
      "headsets": ["Occipital 10-10 montage"],
      "boards": ["cyton"]
    }
  }
]

Every value above is a placeholder. The numbers are not any approval's limits, and they are not a recommendation: every limit in an envelope is the site's to set, transcribed from its own approval. Only id is required. It must be unique in the list, and it is what a recording names.

Identity key Meaning
id, title, authority, reference, version Which approval this is. id and version are stamped into every run locked under it.
approved_on, expires ISO dates. After expires (the day itself is still inside), nothing locks under the envelope. Expiry is never checked when the profile loads: a profile that stopped loading on its expiry date would take the machine down with it.
document, document_sha256 The approval's own document, kept beside the profile (git-ignored) and bound by its SHA-256. A relative path is relative to the profile's folder. The two go together, and the lock refuses a document that is missing or no longer matches its hash.

Every permits key is optional, and an absent key restricts nothing. Ranges are {"min", "max"}, inclusive, with either end optional.

permits key What the lock refuses
paradigms A run whose paradigm is not listed.
flicker_hz A flickering target outside the range. A contrast-reversing target must be inside it at both f and 2f, the same "worse of the two" rule the risk rating uses. A steady target (contrast 0, not reversing) is not flicker.
colour.achromatic_hz A target flickering inside this band (at f, or 2f when reversing) whose colour, background or any state it shows is not black, white or grey. Colour is judged exactly as the shipped chromatic-flicker warning judges it.
colour.saturated_red: false A flickering target involving saturated red, including red–blue, at any frequency.
colour.red_blue: false A flickering target that alternates saturated red with blue, at any frequency, for an approval that rules out that pairing but not red itself. saturated_red: false already implies it.
target_size A flickering target wider or taller than max_deg degrees of visual angle, when it flickers inside band_hz (at every frequency when there is no band).
max_cpd A checkerboard or grating above this many cycles per degree.
max_cycles_across_width A checkerboard or grating that draws more light–dark cycles across its width.
max_cycles_any_orientation A checkerboard or grating that draws more light–dark cycles in any orientation: the worst of its width, its height and, for a checkerboard, its diagonal. Counted by the same function the shipped Harding warning uses, so the two never disagree about a target.
max_targets A run with more targets.
trial_stim_s A trial whose usable window (stim_s) or whose actual flicker (stim_s + onset_offset_s) is outside the range.
headsets, boards A run whose headset (by the toolbox's names, older names mapped forward) or amplifier board (acquisition.board) is not listed, or that declares none.

Some consequences worth knowing before you write one:

  • Width, or every orientation: say which your approval means. max_cycles_across_width counts the cycles across a target's width, the way the renderer sizes a pattern. max_cycles_any_orientation also counts a checkerboard's height and diagonal, as the shipped Harding warning does. A 2° board at 2 cyc/deg draws 4 cycles across its width and 7.5 along its diagonal: under "max_cycles_across_width": 5 it is warned about and not refused, and under "max_cycles_any_orientation": 5 it is refused. The two keys are independent; set whichever your approval's wording describes, or both.
  • The colour thresholds, exactly. saturated_red judges linear R/(R+G+B) ≥ 0.8. red_blue's blue half requires blue to be strictly the largest channel — a tie, as cyan or magenta make against green or red, is not blue-dominant — and at least 0.5 of R+G+B. achromatic_hz's grey judges within 0.01 of neutral grey in CIE 1976 u′v′. These are the exact numbers a site binds itself to when it writes a colour limit; "judged exactly as the shipped warning judges it" above means these three constants, read from the code, not a separate colour model.
  • Both ends of a band are inclusive. "achromatic_hz": {"max": 30} holds a target flickering at exactly 30 Hz to black, white or grey. If your approval allows colour at its boundary, write the boundary just below it.
  • What cannot be shown to be inside is refused. A size or a spatial frequency in degrees needs the viewing geometry. A norm-unit design without the display's resolution, diagonal and viewing distance cannot be shown to meet a degree limit, so the lock refuses it and says why. A picture target's colours cannot be checked either, so under colour a picture flickering in the achromatic band is refused, and so is any flickering picture under saturated_red: false or red_blue: false. A pattern the colour check does not know is refused under any colour limit that would need it, for the same reason. The same holds for max_cpd, max_cycles_across_width and max_cycles_any_orientation: a flickering picture is refused under any of them too (its spatial frequency cannot be checked either), and so is a pattern the toolbox does not recognise — except solid, which has no spatial structure to check and stays exempt.
  • A limit the toolbox cannot check is not a key. populations, max_session_minutes and questionnaire_required are refused with a message saying so, rather than loaded as promises nothing keeps. So is the old flat shape: a limit written beside id instead of under permits is refused with a message saying where it goes. electrode_types is never a key, because electrode type is derived from the headset.
  • The _ prefix keeps a note. A key starting with _, anywhere in an entry, is ignored: it loads, is kept through a save, and enforces nothing. An honest transcription of an approval that names a population or a session length can keep it as documentation, "_populations": ["adults"] or "_max_session_minutes": 90, rather than delete it. It is a note for the reader and nothing more; the refusal message for those keys says so.
  • What cannot be read is refused, not skipped. A design value an envelope limit needs but that is not a number (a stim_s of null, a one-element size, a non-numeric frequency) is outside the envelope, with the reason, and the lock refuses it. A numeric value spelled as text ("freq_hz": "40") is read as the number it names; only text that is not a number is unreadable. Build run shows it as one more warning and keeps the rest of its report.
  • Restrict-only. An envelope can only refuse. It never widens what the code permits, and never weakens a shipped warning or the always-available abort.

Setting one up in the Site profile dialog (#547)

An envelope does not have to be written as JSON. Set up Session → Site profile… has an Approvals list in its Governance box, with Add…, Edit…, Remove and Check a protocol…. The form writes exactly the entry shape above and nothing else: no new keys, no change to the stored format. A profile edited by hand and one edited here are interchangeable.

  • Add… and Edit… open one entry. Which approval this is has a field per identity key; dates are typed YYYY-MM-DD. What it permits has one box per permits key, with its unit and a line saying what it refuses. A box left unticked writes no key, and restricts nothing, which is the schema's own rule. A range takes a minimum, a maximum or both; a blank end is open.
  • The form refuses what the profile would refuse. On OK the entry goes through the parser the loader uses (ssvep.runtime.approval.parse_entry). An empty range, a minimum above its maximum, a date that is not one, a non-number, an empty list of names, or an id another entry already uses is reported in the entry dialog, and nothing is stored until it is fixed.
  • Editing keeps what an entry holds. A field you did not change keeps its exact spelling in the file, and every _ note ("_populations", a _comment) stays where it was — except a note inside a limit's block when you untick that limit: the block, note included, is removed. An entry you open and close without a change is not rewritten at all. As with every save from this dialog, the file is backed up first (§8a).
  • The approval document is hashed on this machine. Choose document… reads the file you pick and stores its SHA-256 and its path. The document is not copied, moved or sent anywhere. Keep it beside the profile (git-ignored, like the profile) and the path is stored relative to the profile's folder, which is how the lock resolves it. A relative path stays relative to whichever folder the profile is in: Save As… into another folder (or copying the profile elsewhere) leaves it pointing at a file that is not there, unless you move the document too. The entry dialog's status line says so, and the lock refuses until it is fixed. The entry dialog says whether the file named still matches the stored hash. When the approval document is replaced by a new version, put the new file in place, press Recompute hash, and bump version.
  • Check a protocol… picks a session protocol file and lists, per envelope, everything the lock would refuse it for: an expired envelope, a document that no longer matches, and every run × every limit crossed. It checks the envelopes as they stand in the form, saved or not, against the protocol as a session resolves it (its session hardware included). These are the lock's own checks, made as for a study session. It changes nothing. A pilot session would be warned about the same findings instead of refused, a bench session is not checked, and the as-run headset and board are checked again at Start.
  • Only a lab profile gates. The form edits an envelope on any kind, and says when the kind means it will be ignored. A list the file holds but cannot read (possible only on a non-lab kind) is shown as unreadable, kept exactly as written, and not editable here: fix it in the file.
  • A locked profile shows its envelopes (View…, and Check a protocol…) and changes none of them.

A worked example. Suppose a site's approval says the following. It is invented: every value is a placeholder, not any real approval's limits, and not a recommendation.

SSVEP and resting-state runs only. Flicker from 6 to 40 Hz. Up to 25 Hz, flicker in black, white or grey only, and no saturated red at any frequency. Targets no larger than 4° when flickering up to 25 Hz. At most 9 targets. Trials of 1 to 5 s. Only the amplifier and headset named in the application. Approved 2000-01-01, expiring 2099-12-31.

In the dialog:

  1. Add…. Id example-study, title Example SSVEP study, authority Example Research Ethics Board, reference 0000-0000, version 1, approved on 2000-01-01, expires 2099-12-31.
  2. Choose document… and pick the approval letter saved beside the profile, e.g. config/example-approval.pdf. The document field reads example-approval.pdf, the hash fills in, and the status line says the file matches.
  3. Tick Paradigms: ssvep and resting. Tick Flicker frequency: from 6 to 40 Hz.
  4. Tick Colour of flicker. Tick Only black, white or grey inside a band, from blank to 25 Hz (a blank minimum is open), and untick Saturated red is permitted in flicker. Both ends of a band are inclusive; if the approval allows colour at 25 Hz, write 24.9.
  5. Tick Target size: 4°, and Only when flickering inside a band, from blank to 25 Hz.
  6. Tick Targets per run: 9. Tick Per-trial stimulation: from 1 to 5 s.
  7. Tick Headsets and pick the montage from Insert a known name…. Tick Amplifier boards and type the board id, e.g. cyton.
  8. OK. Then Check a protocol… on the protocol you mean to run, fix what it lists on Build run or Build Protocol, set the kind to lab, and Save.

The entry it writes:

{
  "id": "example-study",
  "title": "Example SSVEP study",
  "authority": "Example Research Ethics Board",
  "reference": "0000-0000",
  "version": "1",
  "approved_on": "2000-01-01",
  "expires": "2099-12-31",
  "document": "example-approval.pdf",
  "document_sha256": "<64 hexadecimal characters>",
  "permits": {
    "paradigms": ["ssvep", "resting"],
    "flicker_hz": { "min": 6, "max": 40 },
    "colour": { "achromatic_hz": { "max": 25 }, "saturated_red": false },
    "target_size": { "max_deg": 4, "band_hz": { "max": 25 } },
    "max_targets": 9,
    "trial_stim_s": { "min": 1, "max": 5 },
    "headsets": ["Occipital 10-10 montage"],
    "boards": ["cyton"]
  }
}

A limit the approval states that the toolbox cannot check (a participant population, a total session length) has no box. Keep it in the file as a _ note if you want it on record (above).

Where it acts:

  • Build run warns while a design is outside an envelope, naming the envelope and the limit. The design still saves and previews, because exploratory work that never reaches a participant must not be blocked. A Build run design has no hardware yet, so headsets and boards are checked only at lock.
  • Set up Session chooses, and the lock refuses. The Approval row lists the envelopes that gate this machine. With one, it is preselected. With several, the operator must choose, and the lock refuses until they do. 🔒 Lock protocol and session details refuses a protocol outside the chosen envelope, naming every run and every limit crossed, and the envelope's id and version. It also refuses an expired envelope, and a document that no longer matches. The lock freezes the choice. Expiry is checked again at prep and Start, so a session locked the day before its envelope expires cannot go on recording as inside it the day after (the stamp carries no date, so nothing afterwards could tell).
  • Every run records which envelope. The run's session_position.approval carries whichever of the envelope's id, version, reference, authority and document_sha256 are set. It records no date of the session. With no envelope gating, the key is absent.
  • Start checks the hardware actually used. The protocol's hardware is checked at lock, and the Set up Session Amplifier and Headset fields stay editable afterwards so the bench can correct them. So preparing a run, and again Start, re-check the run as it will be recorded (the design with those fields merged in) against the envelope the session was locked under. A headset or board the envelope does not permit is refused there, naming the limit, and nothing is written: no recording names an envelope over hardware it forbids. There is no operator override; a pilot / development session is warned instead (below).

Only a lab profile gates. An envelope on a teaching or generic profile is kept, and ignored: nothing is checked against it and nothing is stamped. The site-profile form warns when the kind means that. The profile still loads, because one mis-click on a class machine's kind must not stop the class, and a save never deletes the list. That holds for a malformed list too: on a teaching or generic profile, an entry the loader cannot read (the old flat shape, a key it refuses, a list that is not a list) is kept exactly as written and reported in the site-profile form, and the profile loads. Only a lab profile is strict about the shape, and the form refuses to save a profile as lab while its list cannot be read.

Piloting before approval

An envelope must never stop the work that comes before an approval: piloting a paradigm, and developing the protocol an application will describe. Two things make sure it doesn't.

  • Design is never blocked. Build run only ever warns about an envelope; Build Protocol does not check it at all (to see what a whole protocol would be refused for before taking it to Set up Session, use Check a protocol… in the Site profile dialog, §3a). A design outside every envelope, or under an expired one, still validates, saves and previews. The envelope refuses only at the lock, and only for a study session.
  • A pilot / development session is declared, and then warned, not refused. Set up Session's Purpose has a Pilot choice (a lab or generic machine offers it), which also needs the operator's two attestations: the volunteer is a team member named in the protocol, and gave oral consent. Nothing is preselected; the purpose is frozen by the lock like identity, and kept across an Unlock and re-lock of the same subject and session but cleared as soon as the Subject, Session, Output dir or protocol changes (and in a new window), so the next participant never inherits it. While a pilot session is locked, the lock status line and the Session checklist's title both say PILOT / DEVELOPMENT — not study data. With it chosen, every envelope finding (every design limit at lock, and the as-run headset and board at prep and Start) is shown as a warning and the session goes ahead. Choosing which envelope still has to be done when there are several, because that is what its runs are stamped with.
  • A bench session is not checked at all. With Bench chosen (no person in the chair), the envelope does not apply: nothing is checked at lock, prep or Start, and no envelope is stamped. Nobody is at risk, and the safeguards are there to help an operator avoid a mistake, not to police them (docs/DATA_GOVERNANCE.md, "Session purpose and data-use tier").

What a pilot session records, so it can never pass for study data:

  • the data-use stamp data_use.purpose = "pilot" in every file it writes (runs and questionnaire answers), with the two attestations (data_use.attestation) and no name. Recordings made before the stamp existed carry session_position.pilot = true instead, which is still read, only to exclude.
  • session_position.approval.status, next to the envelope's id and version: "pilot — within envelope" or "pilot — outside envelope", decided per run (a run whose as-run hardware is outside the envelope is marked outside even when the lock found the design inside). How pilot data is kept out of study results, per session:

  • Every between-condition contrast excludes each pilot recording, reading its own sidecar, and lists it above the results as recorded as a pilot / development session (docs/CONTRASTS.md, "Who is included"). This is the rule that holds. It depends on no conversion step and no participants.tsv edit, and a participant's study recordings from another session stay eligible: piloting yourself on Monday and being a participant on Wednesday under one code does not pool the two.

  • The BIDS converter mirrors it in the participant's sub-XXX_sessions.tsv: session_type is pilot for that session, documented in sub-XXX_sessions.json. Every conversion path does this identically (the 01_xdf_to_bids.py script, and Analyze's own conversion and batch).
  • An export holds every pilot session back, under either purpose (docs/EXPORT.md §1).
  • participants.tsv group becomes pilot only when every recording of that participant is a pilot session, and only when no group is declared. A group set by hand is never overwritten: a conflicting one is kept, and the conversion warns. If the converter set pilot itself and a study recording later appears, it clears its own pilot back to n/a (with a warning); a pilot set by hand stays.

Giving a pilot their own participant code is still the tidier habit; it is no longer the only thing keeping the two apart.

A study session (the box unticked) keeps the strict refusal. The checkbox is a declaration the operator makes and the recording keeps, not a way round the envelope for study data: anything recorded with it ticked is excluded from contrasts by construction. There is no operator override for a study session.

The envelope is the deployment's own record of what it transcribed. Nothing in this repository states an approval or whether one was granted: the shipped example uses placeholder values, and the profile file stays git-ignored. The approval entry got its shape in schema 1.2. A 1.1 or 1.0 profile with no approval entries loads unchanged. On a lab profile, entries in the old flat shape are refused, with a message saying to move the limits under permits; on any other kind they are kept and warned about, as above.

4. Push: a profile can forbid, never enable

push.host / user / remote_base are the deployment's default destination; config/push.local.json (the Setup tab writes it) overrides them per machine, and every file the GUI has ever written names all of those fields explicitly, so existing lab machines are unaffected by the change.

There is deliberately no enabled key in a site profile — writing one is refused with an error that says so. A profile that could switch transmission on would be a profile that widens what the code does, and a fresh machine must never transmit data unasked. Turning auto-push on stays an opt-in made on the machine itself.

push.allowed: false goes the other way and forbids pushing outright. The refusal is at the transport, not merely at the toggle: test_connection, remote_index and push_paths all stop before touching the network, so a caller that never consulted a setting still cannot transmit. allowed is read from the profile every time and is never written into push.local.json — a machine-local file that anyone can edit must not be able to lift a restriction the deployment declared.

That flag is a guardrail, not the protection. The actual protection is that a teaching machine has no key on the research server and the server does not list it. Do not relax the server side because the toolbox has a flag. The transport and its consent gate are documented with the push subsystem itself.

5. Standing constraint: a profile may only restrict

A site profile may only ever make the toolbox more restrictive, never less. The photosensitivity warnings and the always-available abort (see the photosensitivity section of the safety documentation) can never be weakened by configuration — in a room of undergraduates they matter more, not less — and neither can the consent gate or de-identification.

This is not enforced by guarding those checks against a profile. It is enforced by there being no key that could reach them, and by tests/test_site_profile.py failing if one is ever added: one test walks every property name in the schema and rejects anything that looks like a switch turning a safeguard off, and another runs the safeguards under all three profile kinds plus a maximally-permissive one and asserts they still fire. If a deployment genuinely needs to be stricter than the default, express that as a field whose restrictive value is the non-default one, the way push.allowed does.

data_use.tier_entry (§6b) is not a consent switch. It chooses how the participant's option reaches the toolbox, not whether one is needed: under either value the option is required, the same purpose × tier matrix decides what it allows, option 0 still writes nothing, and the stamp says truthfully who entered it.

6. Operators: provenance, not access control

Every recording is stamped with who collected it — that identifies the researcher, never the participant, and it is the only way to ask "who ran this?" two years later.

A non-empty operators.whitelist turns the operator field into a selection: a name must be chosen before recording, and ssvep.runtime.auth.resolve_operator refuses a name that is not on the list. No whitelist means free text. default is blank unless you set it, because a pre-filled name is a name that ends up on someone else's recording.

The whitelist is not an access control and must not be described as one. It is a file anyone with the machine can edit, so refusing an unlisted name protects nothing. What actually protects the data is elsewhere, and the toolbox does not simulate it:

  • the server — an account exists on it or it does not, an SSH key is registered or it is not. The push transport authenticates with the OS SSH-key chain under BatchMode=yes, and there is no secret anywhere in this repository, so a machine without a key simply cannot transmit. A class cannot push because of that, not because of a flag;
  • the consent gate, re-read off each session's sidecar at the moment of any transfer, never inherited from whoever asked for it;
  • the lab's own storage controls (R4), which this layer sits on top of and never relaxes.

What a list does buy is that names stay spelled the same way across a study and nobody types a colleague's name from memory. That is the whole claim.

6a. Questionnaires: opt in, never inherited

The toolbox ships two built-in instruments (ssvep.runtime.questionnaire): post-condition, the break-time symptom checkpoint asked after each condition, and end-of-session, asked once at the end. Both are transcribed verbatim from one institution's ethics appendix.

The shipped default is that a new protocol starts with neither. That is the whole point of putting them here. An instrument written for one REB is not a property of the toolbox: a lab under a different board has different wording and different items, a teaching or hobbyist deployment has no board at all, and a developer testing the record loop should not have to dismiss a symptom questionnaire on every run. Inheriting an ethics artefact by default would put someone else's paperwork in front of all three.

A deployment that is required to administer them opts in by id:

"questionnaires": { "defaults": ["post-condition", "end-of-session"] }

The list is ordered but order does not matter — each instrument has its own slot in a protocol. Listing one without the other is normal. The Site profile dialog lists the built-in instruments as checkboxes under Questionnaires, none ticked by default; saving keeps the file's own order for the ones still ticked. An unknown id is refused, not ignored: a typo would otherwise mean "no questionnaire", and nobody would find out until a session had already run without the checkpoint it was supposed to have.

What the policy governs is only what a protocol starts with. Everything downstream is unchanged: an operator can still add, edit or remove a checkpoint per protocol, default_for still restores an edited instrument to its pristine form, and is_built_in_default still flags divergence from the approved wording for deployments where that matters. What it cannot do is conjure an instrument the site never opted into — "Restore defaults" adds nothing under a profile that lists none, because a button that reintroduced exactly what the profile declined would make the setting a suggestion.

Upgrading an existing deployment: the built-ins used to be attached unconditionally, so a site that relies on them must now say so. Add the block above to its profile; without it, newly authored protocols start clean. Protocols already saved are untouched — the policy is read when a protocol is authored, never when one is loaded or run.

6b. Data use: who enters the participant's option

A research session is stamped with the data-use option the participant chose (docs/DATA_GOVERNANCE.md). How that choice reaches the toolbox is a lab's procedure, so it is a site setting:

"data_use": { "tier_entry": "participant" }
  • participant (the default): the participant picks their own option on screen. Set up Session's Purpose asks the operator for no option; when the operator presses Lock, after the protocol is loaded and resolved and every operator-side check has passed, the participant screens run fullscreen on the stimulus monitor: the safety screen (the toolbox's core statements plus any the protocol adds, one answer for the whole list), the protocol's consent form (on_screen consent only; "I consent" is enabled once it has been scrolled to the end), then the data-use question in the resolved wording. The data-use question is asked in paper and none modes too, so the recording holds the participant's own answer. The session locks only once the participant has chosen, and the stamp says entered_by: "participant". Esc (or the screen's small operator stop) at any point, a statement that applies, declining the form, or an option below the protocol's minimum leaves the session unlocked with nothing written; the operator is told which, and after a safety-screen stop only that the session can't go ahead. Option 0 locks the session as observer only, which writes nothing. Nothing from the screens is kept except the chosen option, in memory, until the lock stamps it. The option is held for the subject, session, output folder and protocol it was given for, and for exactly the wording and safety statements the participant was shown: an unlock and relock of the same session keeps it without asking again, and any change to one of those, before or after a lock, means it is never used and the screens run again. A lock that fails after the screens (for example, files already saved carrying an option this protocol can't offer) drops it too. When the session already holds recordings, the question about continuing it or starting a new one is asked after the participant has chosen, against the option they chose.
  • operator: the operator enters the option the participant stated (on a paper form, or aloud), and the recording is stamped entered_by: "operator". A protocol whose consent is administered on screen (consent_mode: "on_screen") cannot be locked for research under this setting: on-screen consent runs only under participant, and an operator-entered stamp would claim a form was shown that was not. Use a paper (or none) protocol, or set participant.

The screens take mouse and keyboard alike (decided on #560): every choice is a large card to click, the number keys pick an option (1 or 2 on the safety screen, the option's own number on the data-use question), Space or Enter continues, and the arrow keys, Page Up/Down, Home and End scroll the consent form. A choice is highlighted first and confirmed with Continue, so a stray key never answers. The text is sized for reading at 60 to 100 cm. A teaching session shows the student the safety screen, and only that, at every lock (a class is not human-subjects research, so there is no consent form and no data-use question; the operator still enters course use only or option 0). A statement that applies, or Esc, leaves it unlocked with nothing written, and nothing about the answer is recorded. Pilot and bench sessions show no participant screens; a pilot's safety screening is done in the study's own materials.

Pilot, bench and teaching sessions do not read this setting. A value other than these two is refused when the profile loads, never read as either. The Site profile dialog sets it under Data use, and shows what each value means for a research session beside the control.

7. Missing is fine; broken is fatal

State of config/site.local.json What happens
Absent Generic profile. Normal, supported, silent.
Present and valid That deployment's profile.
Present and unparseable — bad JSON, an unknown kind, a misspelled key, a default operator nobody may select SiteProfileError, loudly. Never a fall back to generic.
Named by SSVEP_SITE_PROFILE but missing, unreadable, empty or relative (§10) SiteProfileError, loudly. A profile asked for by name is never "absent".

The asymmetry is deliberate. "Absent" means nobody has configured this machine, which is a normal state. "Present and broken" means somebody did configure it and got it wrong — and silently demoting that machine to generic would take the institution and the ethics reference off real participant data while reporting success. Unknown keys are refused rather than ignored for the same reason: a profile is read once, by nobody, on a machine that then records participants, and a mistyped institution would otherwise load as a deployment that stamps nothing.

8. The GUI half: the screens that make one

Copying site.example.json by hand (§2) still works and is still the fastest route on a machine you can already reach a text editor on. The app also builds one for you, through four pieces that all read the same site API rather than a copy of the profile:

Screen What it does
First-run prompt On launch, when no config/site.local.json exists, offers to set one up. Dismissing it writes nothing — staying generic is a supported answer (§1), not a deferral.
Site profile dialog Creates or edits a profile: institution, governance, dataset, push, questionnaires, data use (tier_entry), operators, line frequency. It names the file it is editing, says whether that is the active profile (the file the toolbox reads: SSVEP_SITE_PROFILE when set, else config/site.local.json, §10), and warns when the active one is in a temporary folder. Blocks it has no editor for (paths, publish, studies) are carried through a save unchanged; it shows how many publication-guard literals the file holds, never the literals themselves. It edits the approval envelopes field by field, lists what a protocol would be refused for under them, and warns when the kind is not lab and they will be ignored (§3a, "Setting one up in the Site profile dialog"). A locked profile opens with every field disabled, including the operator-table buttons. An invalid form never overwrites a good file on disk. See §8a for how it saves.
Window title Names the active profile, so the deployment a session is about to be stamped with is visible without opening anything.
Operator field Derives its shape from the profile: free text under generic, a required dropdown when operators.whitelist is set. The whitelist is enforced where provenance is actually written (auth.resolve_operator), not only by the widget — a widget is a convenience, not a control (§6).

The API underneath, for anything that needs the profile without going through a screen: site.load_profile, site.save_profile, site.active_profile, site.SiteProfile.describe, auth.operators, auth.requires_selection, auth.default_operator_name, auth.resolve_operator.

One consequence worth knowing before you configure a machine: 🔒 Lock protocol and session details refuses to lock without an operator (RunTab.lock_blocker — it goes into every run's provenance), so nothing can be recorded, or exercised end to end in the GUI, until one is set. Under generic that is satisfied by typing a name. Putting the names in operators.whitelist instead turns the field into a fixed choice, which is the better arrangement on any machine used repeatedly — including a development one, where retyping a name before every test is what you would otherwise be doing. A generic profile carrying a whitelist is a legitimate profile, not a half-configured one.

8a. How the Site profile dialog saves, and named profiles (#549)

A save once emptied a lab's publish.forbidden_literals and operators.whitelist, with no backup. The dialog now works like this:

  • A save changes only what you changed. The file is read as written, and a save merges the changed values into it. Comments (_comment and any other _ key), key order, an approval entry's own comments, empty optional lists and an operator listed as github_username are all written back as they were. A save that changes nothing leaves the file byte-for-byte identical.
  • An unreadable file is never saved over. If the file exists but does not load (bad JSON, a key the loader refuses, a literal under four characters), the dialog shows why and turns Save off. Saving the form's defaults over it would replace everything it held. Fix the file by hand and press Reload from disk, open another file, or use Save As…. The same applies when the file breaks, or is edited by hand, after the dialog opened: a broken file is refused, and a hand edit is overwritten only if you say so.
  • Every overwrite is backed up first. The old file is copied into site-profile-backups/ beside it, named <name>.<YYYYmmdd-HHMMSS-micro>.json, and the newest 10 are kept. The folder holds its own .gitignore (*), so it is ignored in whichever repository the profile sits in, and the toolbox's .gitignore also lists config/site-profile-backups/. The backups hold the same deployment facts as the profile, so they never leave the machine. If a backup cannot be written, the save stops.
  • Fewer publication-guard entries means a question first. Before any write that would leave a file with fewer forbidden_literals, history_literals or history_patterns than it has now (Use generic instead, Save As… over another profile, Make active…), the dialog asks, and gives counts, never the values. The default answer is No. Use generic instead always asks before replacing an existing file.
  • Open… edits another profile file. It does not change what the toolbox uses; the dialog says the file is not the active one.
  • Save As… writes the form to a new file (comments and all, as above) and goes on editing that file.
  • Reload from disk re-reads the file, so a hand edit applies without restarting. When it is the active profile, Set up Session follows when the dialog closes, whether you save or cancel: the deployment label, the window title, the operator field, the approval list and the data-use form (its purposes, and who enters the option). If the file no longer loads, the running toolbox keeps the profile it already had and the dialog shows the error.
  • Make active… copies the opened file's contents, byte for byte, over the active profile file, after a backup and a confirmation that names both files. It never changes SSVEP_SITE_PROFILE. To point a machine at a different file permanently, set that variable yourself (System Properties -> Environment Variables on Windows) and restart; the confirmation says so, and warns against pointing it into a temporary folder (§12, #480). A locked active profile is never replaced from here. A profile with unsaved changes must be saved first, because what is copied is the file on disk.

The setup wizard reuses the same form, without the file buttons: there it only captures the profile for the planner to write.

9. Keeping a profile on a machine you also develop on

Supported, and it does not have to be the unconfigured one: the test suite never reads config/site.local.json. tests/conftest.py pins every test to the generic profile and points site.profile_path() at a sandbox that does not exist, so a test that clears the active profile mid-run falls through to generic rather than to your file. Without the second half the first is not enough — clearing the pin means "re-read disk" — and the symptom is ugly: a profile as innocuous as generic-with-a-one-name-whitelist turns the operator field into a dropdown and fails a test about free text, on your machine only, with nothing in the diff to explain it. tests/test_site_profile.py::test_the_suite_never_reads_this_machines_own_profile fails on every machine if that sandbox regresses.

Two tests opt out with @pytest.mark.real_site_profile_path, because they are the ones asserting where the real file lives. Neither reads its contents.

The same fixture also clears SSVEP_SITE_PROFILE (§10) for every test, so a machine set up from a lab repository — which exports it — runs the suite exactly as a fresh clone does.

10. Keeping the profile, designs and data outside the checkout

A lab that runs the toolbox on several machines usually wants its own material — a profile per machine, its own protocols and run manifests, its governance documents — in a private repository of its own, with a clone of the toolbox beside or inside it that it only ever pulls. Three generic hooks make that work without editing a single tracked file in the toolbox (#298). None of them is lab-specific, and none is needed by a single-machine or generic deployment.

SSVEP_SITE_PROFILE names the profile file, as an absolute path. Set, it replaces config/site.local.json for reading and writing: the profile dialog edits that file and says so. Unset, nothing changes. A profile asked for by name is never optional — set but missing, unreadable, empty or relative is a SiteProfileError, the same as a broken profile (§7), because falling back to generic there would record under the wrong deployment on exactly the machine that was configured. The GUI then refuses to start with a message naming the file and the fix, rather than a traceback. That also means the first-run prompt never appears for a profile named this way: create the file first (copy config/site.example.json), then set the variable.

Set it at Machine scope (Windows: System Properties → Environment Variables → System variables, or setx SSVEP_SITE_PROFILE "D:\example-lab\config\acq-pc-1.json" /M from an administrator prompt), not for one user and not in one terminal. The variable only works for processes that can see it, and the ones that matter are not always yours: the desktop shortcut, a git client, a scheduled conversion, a colleague's account running the pipeline. A process that cannot see it reads the checkout's config/site.local.json instead — which a lab-repo layout does not have — and so runs generic, without saying anything. Two things catch that when it happens: every recording stamps the profile's kind and label into its session sidecar, and conversion refuses a run recorded under a configured profile when the converting process is generic (below). A Machine-scope change reaches programs started after it, so log out and back in (or restart) once.

paths.designs lists extra directories of run manifests and session protocols. The GUI's design file dialogs (load and save a run manifest, add a run to a protocol, load and save a protocol) list them in their sidebar beside the shipped run-manifests/ and session-protocols/. Set up Session resolves a pre-1.2 protocol's run references against the store followed by these directories, and Compare (and BIDS/code/03_run_contrasts.py) look protocols up by name in all of them. A reference, or a protocol name, found in two of them with different content is refused as ambiguous rather than resolved to whichever is listed first. With no extra directories the dialogs are the platform's native ones, exactly as before. A self-contained (v1.2+) protocol never reads a store, so an unreachable design directory never stops one from resolving at the bench.

paths.data_root is the BIDS root recordings are written to and read from, instead of the checkout's own BIDS/. Recording, the impedance log, push, conversion (scripts/bids_convert.py, BIDS/code/01_xdf_to_bids.py), analysis (BIDS/code/02_run_offline_pipeline.py, the Analyze tab), contrasts (BIDS/code/03_run_contrasts.py, the Compare tab) and export all use it, and the export dialog opens beside it rather than inside the checkout. With a data root declared, pointing anything at the checkout's own BIDS/ is refused, naming both: that path is the default from before the key existed leaking through somewhere, and used it would convert or analyse an empty tree and report success.

Both keys are strict in the same way the rest of the profile is:

  • Absolute paths only, as written. A relative path is refused with an error saying so: against the launch directory it would mean somewhere different on every launch (the desktop shortcut starts in the environment's Scripts folder), and ~ is not expanded. On Windows, a path with no drive letter (\lab), a drive-relative one (D:lab), and a network path with no share (\\server) are each refused with their own explanation.
  • Unknown keys under paths are refused, and so is null for a list: omit the key, or write [].
  • Checked when used, not when loaded. A profile must stay loadable on a machine whose data drive is unplugged — to edit it, or for the literal guard (§11). The moment a directory is used, a missing one is an error naming it, never a silently shorter list or an empty tree. The GUI still shows a declared data root that is missing, rather than quietly substituting BIDS/; the refusal comes when something tries to record, push, convert, analyse or export there. A missing design directory is reported by a warning before a design dialog opens, which then offers the directories that do exist.
  • A data root is never created for you. A typo would otherwise start a second BIDS tree that nobody backs up or pushes from.
  • A data root must not be inside the toolbox checkout, or inside any git working tree. Participant data never goes where git add can reach it (AGENTS.md §4) — which is the reason to move it out of the checkout at all. It must also be storage your own data-governance rules approve; the toolbox cannot check that part.
"paths": {
  "designs": ["C:/example-lab/designs"],
  "data_root": "D:/example-lab-data/BIDS"
}

Every recording says which deployment made it. The session sidecar carries site_profile: {"kind", "label"} from the profile active at record time — deliberately not the profile's file path, which contains the machine's user name, and sidecars travel to lab storage with the data. Conversion reads it: a run recorded under a lab or teaching profile is refused when the converting process is running generic, with a message naming both, rather than being stamped with no institution or governance. scripts/bids_convert.py and the BIDS/code/ scripts print the active profile as their first line for the same reason. Sidecars from before the stamp existed carry none and convert as they always did.

The profile schema is 1.1 for these keys (and publish, §11). A 1.0 profile loads unchanged. A toolbox reading a profile written for a newer minor version than it knows says so — "written by a newer toolbox" — rather than listing the new keys as mistakes.

10a. Studies: one data root per study (#396)

A study is one BIDS dataset root (AGENTS.md §2.1). An install that runs one study needs nothing here: with no studies, there is one implicit study rooted at paths.data_root (or the checkout's BIDS/), and nothing changes. An install that runs more than one — two research studies, a pilot and its main study, a lab study and a class on the same PC — declares each:

"studies": [
  {"id": "example-a", "name": "Example study A", "data_root": "D:/example-lab-data/example-a"},
  {"id": "example-b", "name": "Example study B", "data_root": "D:/example-lab-data/example-b"}
]
  • id is lower-case letters, digits, - and _. It is stamped into every recording and answer (session_position.study), written into the root's marker file, named in dataset_description.json (SSVEPStudy, exports included), and used as the study's folder under the push destination, so it must never be a participant code, a date or anything identifying.
  • An id is permanent once anything is recorded under it. The marker and every recording's stamp name it, so renaming a study in the profile does not rename its data: every entry point then refuses the root, naming both ids. A new id means a new root. To give a study a friendlier label, change its name, which is display only.
  • data_root follows every rule of paths.data_root above: absolute, never created, outside the checkout and every git working tree, checked when used. No two studies may share a root, and none may sit inside another's: a study is one root, and two in one tree would share codes and pool.
  • destination (optional) names a saved push destination from this machine's config/push.local.json that the study pushes to by default. A name only: the host, account and key never go in the profile. The study's folder under that destination is still derived from the tree. A profile using it is schema 1.6; without it the profile keeps the version its other keys need (1.4 with studies alone), as before. A name this machine has not saved is refused at push time, not redirected (the push guide).
  • paths.data_root and studies are mutually exclusive. Once studies are declared, each names its own root, and a profile with both is refused rather than left with two answers to where a recording goes.

What it does:

  • A protocol names its study (study, protocol schema 1.6; docs/PROTOCOLS.md). Set up Session points Output dir at that study's root when the protocol is loaded, and the lock refuses any other root, a protocol that names no study, and one naming a study this profile does not declare.
  • The root says which study it is. The first lock into a study's root writes .ssvep-study.json there (dot-prefixed, so the BIDS validator ignores it), and push and export carry it with the data. A profile edited to point one study at another's root is refused, naming both — the relabelling #279 warned about. Where a tree has no marker and the profile says nothing about it (the compute server, whose profile declares no studies; a copy made by hand), its recordings' own stamps say which study it is when they all agree, so its contrasts and Compare work there as on the acquisition PC. Stamps that disagree, or a file in a stamped tree that cannot be read, are refused.
  • One root holds one study, at every entry point. Recording, the impedance log, conversion, Analyze, Compare and contrasts, export and push all refuse a root holding a file stamped with another study, or an unstamped one (a recording from before the study was declared), and name the files. In a root that belongs to no study they refuse files of more than one. Compare and the contrast scripts read only the root's own study's protocols, so another study's protocol of the same name is neither its contrast nor a conflict with it.
  • Push goes to the study's own folder: <remote_base>/<id>/sourcedata/… (the auto-push guide, §2). The implicit study pushes to the base itself, as before.
  • The GUI fields start on the first study's root, and Analyze and Compare can be pointed at any other. python -m ssvep.io.declarations needs --root when more than one study is declared.
  • The same person in two studies gets a new code in each, in each study's root. Nothing links the two codes.

The profile schema is 1.4 for this key. A 1.3 profile loads unchanged, and a profile with no studies is still saved as a 1.3 document (no studies key), so a PC on an older toolbox keeps reading it.

11. The literal guard: a lab's identifiers come from its profile

The leak guards need to know the strings a deployment never publishes — its server host names, service accounts, ethics file numbers. Those are facts about one deployment, so they are configuration:

"publish": { "forbidden_literals": ["0000-0000", "data.example.ca", "example-data"] }

Each is matched case-insensitively as a plain substring. Each must be at least four characters, because anything shorter matches ordinary text. The profile is not a secret store: anyone with the machine can read it.

Three things read the list from the active profile:

  • scripts/check_forbidden_literals.py, a pre-push hook for a clone of the toolbox. It refuses a push if anything new to that remote carries a literal: a commit's message, author or committer, the paths it touches, the lines it adds, or an annotated tag's name, tagger and message. "New" is per remote — a branch already on a private remote is still checked in full when it is pushed to a new public one. It does not read the content of binary or -diff files (their paths are still checked), it matches the literal as written (a URL-encoded or hard-wrapped copy is a different string), and git push --no-verify skips it like any hook: it catches an honest mistake, and it is not an access control.
  • Install it with python scripts/install_literal_guard.py. That writes the hook to the directory git runs hooks from and refuses to overwrite a hook it did not write (it prints how to chain the guard into that one instead). The hook names no literal itself; it reads the profile at every push, so editing the profile is enough.
  • The installed hook requires literals. A push that finds none — typically because the process running git cannot see SSVEP_SITE_PROFILE — is refused, because that is the guard losing its profile, not a clone with nothing to protect. --optional installs a hook that lets such a push through instead. A profile that is present but broken always refuses.
  • A clone that uses this repository's .githooks/ (git config core.hooksPath .githooks) gets the guard from that hook instead, where it runs whenever a site profile is configured (SSVEP_SITE_PROFILE is set, or the clone has config/site.local.json). Set SSVEP_REQUIRE_LITERALS=1 on a machine that must never push unchecked, and set SSVEP_PYTHON if git's shell cannot find the toolbox environment's Python.
  • This repository's pre-publish guards: scripts/publish_snapshot.py for the derived snapshot repositories, and scripts/scan_built_site.py for the documentation site. A derived snapshot does not carry either. The guards have always run two kinds of check, and only the literal half moved: the generic shape checks — an ethics-file-number shape, private-key blocks, access-key shapes, the participant-derived report marker, the protected-data paths — are code and run everywhere, with two narrow exemptions from the ethics-number shape only: a bibliography entry's page range, and one third-party licence notice that must ship verbatim, pinned to its sha256 (the publishing guide has both). Both print which literals they used, or that there were none. --require-literals makes either refuse when there are none, and publish_snapshot.py --publish requires them unless given --no-literals-ok: a publishing machine that cannot see the lab's profile is exactly the case the guard exists for. A dry run keeps the plain behaviour.

A history scan reads two more lists (schema 1.5, #153). scripts/scan_history.py checks every object a repository's history holds — every path any commit ever had, every version of every file, every commit message and author line — before that repository is published with its history. It uses the same literals and shapes as the guards above (it imports them, so it cannot disagree with them), plus publish.history_literals (plain substrings, matched like forbidden_literals) and publish.history_patterns (regular expressions, such as a pilot participant-code range). These are for names that must be gone from every old version but which the tree guards do not check: a former brand, the lab's own hardware name. The loader refuses a pattern that does not compile or that matches the empty string. Nothing else reads either list. A saved profile carries the two keys only when one is set, and is stamped with the oldest version that holds what it declares (1.6 with a study destination, 1.5 with history tokens, 1.4 with studies, else 1.3), so a PC on an older toolbox keeps reading a profile that does not use them. The publishing guide describes the scan.

So what a given place can catch depends on whether it has a profile, and it is worth being exact about it:

Where Shapes and protected paths A lab's literal host, account or file number
A lab machine with its profile (publishing, or pushing through the hook) Caught (publishing; the hook runs literals only) Caught
This repository's own CI Caught Caught: the docs-site scan runs with the lab-only config/site.ci.json as its profile and --require-literals, and the real-tree snapshot test loads the same file through SSVEP_CI_SITE_PROFILE
Any other CI job with no profile Caught Not caught, unless it has the shape of an ethics file number. A host name or an account on a published page passes.
A derived snapshot repository's own CI Not run (the scan step is pruned, and the guard scripts are not shipped) Not run

The third row is the price of keeping the literals out of the source: a CI job that knows no profile cannot know a lab's host name. It is paid knowingly, tests/test_publish_snapshot.py and tests/test_scan_built_site.py both pin it, and the second row shows the way out — give the job a profile.

12. Setting up a lab (ssvep.setup)

§10 and §11 describe three generic hooks a lab can use to keep its own material — site profiles, designs, documents — outside the toolbox checkout. Wiring all three up by hand (a folder, config/<machine>.json, a clone of the toolbox, two git hooks, an environment variable) is a lot of fiddly steps and a lot of ways to leak data on the way there. python -m ssvep.setup is the guided path (#299).

Three subcommands, one for each starting point:

python -m ssvep.setup lab      --target D:/example-lab --machine acq-pc-1
python -m ssvep.setup teaching --target D:/example-classroom --machine classroom-1
python -m ssvep.setup join     --target D:/example-lab --machine acq-pc-2 --url <the lab repo's URL>

lab builds a new lab repository from scratch: config/, designs/, docs/, a lab-layer agent file explaining that toolbox/ is public and lab facts go here instead, a .gitignore (participant-data patterns — derived from the same pattern the installed hook checks, plus participants.tsv, qc-reports/, local-config and picture-bundle shapes — toolbox/, secrets — but not config/, which this repository exists to track), and a README.md stating plainly that the repository holds configuration and documents, never participant data, and that wherever it is hosted must satisfy the lab's own data-governance rules. It then runs git init, installs the protected-data guard as this repo's own pre-commit hook (resolved via git rev-parse --git-path hooks, so it still runs under core.hooksPath or in a worktree), stages the skeleton by explicit file path (never a bare directory, never git add -A), makes the first commit, clones the toolbox into a git-ignored toolbox/ and installs scripts/install_literal_guard.py there as its pre-push hook (§11 — reused, not reimplemented), and sets SSVEP_SITE_PROFILE for this user to the new config/<machine>.json. teaching writes just the profile and the environment variable by default — the lab repo is optional for a teaching machine — and takes --full-repo to build the same skeleton lab does. join clones an existing lab repo from --url, adds this machine's own profile to it, and installs both hooks locally, since neither travels with a clone (.git/hooks is never part of what git transfers). Every repo-building action refuses up front if --target already contains anything that looks like participant data, and refuses (rather than silently committing into it) if a refused or failed clone/init would otherwise leave a later step resolving into an enclosing repository.

The command is interactive by default — it shows the plan and asks once before touching anything — or scriptable with --yes (answers the ordinary prompts: writing new files, git init/commit, installing the hooks) and --dry-run (shows the plan, writes nothing). Two things stay opt-in regardless of --yes, because the underlying planner and executor are built around never doing either silently: overwriting a file that already exists (--force-overwrite, or an interactive yes) and any network action — cloning toolbox/ from a remote URL, gh repo create, a push (--confirm-network, or an interactive yes). git is required (with a working user.name/user.email, checked up front rather than left to fail mid-plan); gh is optional — --create-remote asks it to make a private GitHub repository and push to it (not supported on join, which already has the remote it was cloned from), and prints the commands instead of failing the rest of the run when gh is not installed.

Trial runs and temporary folders (#480). Setting SSVEP_SITE_PROFILE outlives the folder it points at: a trial run into a folder under %TEMP% leaves every later launch on that machine using a throwaway profile (wrong operators, no literals for the push guard). So setup refuses, before writing anything, when the profile it would name is inside the system temporary directory (resolved: symlinks, .., Windows short and long forms, case-insensitive on Windows), and says how to proceed. For a trial run add --no-env: it does everything else (writes the files, makes the repo) but does not set the variable, and prints what it would have set. --dry-run still writes nothing at all. --allow-temp-profile overrides the refusal for the rare case where a temp location is really intended. The refusal applies on Windows, where the variable is really persisted; on other platforms the step only prints an export line, so a temp target is harmless. The GUI wizard says so when the folder is picked. As a backstop, when SSVEP_SITE_PROFILE points into the temporary folder and the file still exists, the app starts and shows a warning in its status bar ("using a site profile from a temporary folder: ... usually a leftover from a trial setup"). The status-bar message is transient (the next status message replaces it); the durable record is the entry written to the startup/crash log on every launch. If the temp folder has already been cleaned, the app cannot start (a missing profile is a blocking error) and that error now says the path is in a temporary folder. To clear a leftover: remove the user variable in System Properties -> Environment Variables.

Under the hood this is a pure planner (ssvep.setup.plan.build_plan: a request in, an ordered list of operations out; it writes no file and runs no subprocess, but does read — never write — the target folder to refuse a tainted one before returning a plan) and a separate executor (ssvep.setup.executor.Executor: applies a plan, previewing first). The CLI's own profile fields are deliberately thin — kind, label, institution name — not the whole schema; reach for SiteProfileDialog in the GUI, or edit config/<machine>.json by hand (§2), for governance, push, operators or questionnaires.

The GUI has the same three starting points. ui.site_profile_dialog.FirstRunPrompt's "Set up a profile…" button opens a small chooser — a research lab, a teaching lab or course, or join an existing lab — and ui.setup_wizard (a second thin wrapper over the same build_plan/Executor, alongside the CLI) walks through a folder picker, this machine's profile (the existing SiteProfileDialog form — reused, not retyped, so the governance/ethics fields and #161's approval-envelope note are exactly what the CLI's thin profile cannot offer), a preview of the plan rendered from the executor's own preview(), an explicit confirmation before anything is written, the apply itself with every step's result shown, and a final summary repeating the "never participant data" statement. Overwrite and network confirmation are Qt dialogs wired straight to the executor's confirm_overwrite/confirm_network callbacks and default to refuse on a dismissed dialog, the same as the CLI without --force-overwrite/--confirm-network. SiteProfileDialog takes two optional constructor arguments for this: write_profile (captures the built profile instead of writing it to wherever site.profile_path() resolves — the planner's own WriteFile operation is what writes it for real, into the new repo) and show_generic_button (hidden inside the wizard, since "generic instead" is the first-run prompt's own separate top-level button). Both default to the form's ordinary standalone behaviour, so every other caller is unaffected. The apply runs on a worker thread, so the window stays responsive during a long command such as a clone, and a progress dialog offers Cancel (which kills the running command); the dialog is hidden while a confirmation is open, so it can never cover the Yes/No. Commands run non-interactively: GIT_TERMINAL_PROMPT=0 alone does not stop a GUI credential helper (conda's git configures one in its system gitconfig), so every git command that can authenticate (clone, fetch, pull, push, ls-remote) has its credential-helper list replaced: reset, then Git Credential Manager (with GCM_INTERACTIVE=never) and gh auth git-credential when gh is installed and signed in. Credentials the machine already holds therefore work (a private source after gh auth login), while a missing credential fails fast with a message saying to sign in or use a public URL. Empty GIT_ASKPASS/SSH_ASKPASS and ssh -o BatchMode=yes close the other prompt routes. Git Credential Manager is looked for on PATH and in the standard Git for Windows folders; inside the conda environment it is often not found, so gh auth git-credential (used when gh auth token shows a stored sign-in, an offline check) is the expected path there. A clone that is cancelled or fails removes what it wrote (the new folder, or just the contents of an empty folder you picked) so a retry starts clean; SSH failures get an SSH-specific hint (key and agent), not the HTTPS sign-in one. After a successful apply the running toolbox adopts the new profile at once; the persistent environment variable is for the next launch.