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.

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, because a lab can run under more than one approved protocol at a time; its entry shape belongs to #161, and one field is already settled: an entry names headsets, never electrode_types, because electrode type is derived from the headset.
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.
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.
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.

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 (COMPLIANCE.md R6) can never be weakened by configuration — in a room of undergraduates they matter more, not less — and neither can the consent gate (R5) or de-identification (R2).

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.

6. Operators: provenance, not access control

COMPLIANCE.md R8 wants every recording 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. 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_reb_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.

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.

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 the profile: institution, governance, dataset, push, operators, line frequency. A locked profile opens with every field disabled, including the operator-table buttons. An invalid form never overwrites a good file on disk.
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.

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.