Auto-push — mirroring recordings to the lab server¶
The acquisition PC is not the archive. This is the local disk → lab server → compute server
leg of the data flow (docs/ARCHITECTURE.md): when a run ends, the toolbox copies it to
your deployment's push host over SFTP so it survives the bench and is reachable for analysis.
The host, account and remote path are not written down here: they come from your site profile
(config/site.local.json, #165) and this machine's config/push.local.json. The examples below
use the placeholders from config/push.example.json — substitute your own.
It is opt-in and off by default, it never deletes anything locally, it only sends the run
(or questionnaire) that just finished, and it never pushes observer-only data (COMPLIANCE R5). Code:
src/ssvep/runtime/push.py; UI: the Auto-push block on the Set up Session tab of ssvep-run.
1. One-time setup (per acquisition PC)¶
Authentication is this machine's SSH key — there is no password anywhere in the toolbox, and no secret in any config file.
- Make a key, if this machine has none:
- Give the public key to the server — send
~/.ssh/id_ed25519.pubto whoever administers the push account, to be appended to~/.ssh/authorized_keysthere. - Connect once by hand. This is not optional:
The first connection asks you to trust the server's host key. The toolbox deliberately never
auto-accepts a host key (
StrictHostKeyCheckingis left alone — auto-accepting would weaken the server's own access controls, COMPLIANCE R4), so until you have done this once, every push fails with "host key not trusted". - In
ssvep-run→ Setup → Auto-push: click Test connection. Green ✓ means the key works. Then tick Push recordings to the server after each run.
Settings are written to config/push.local.json (git-ignored) and reload on the next launch, so
this is a once-per-machine job. config/push.example.json documents the fields.
Your deployment's own server: set User, Host, and Path to it. They default to whatever the site profile declares (
config/site.local.json, #165), and the only hard rule is the destination's location — see §6.
Several servers or project folders: saved destinations¶
A machine that pushes to more than one place (a second server, a backup mirror, a different project
folder) keeps them as named destinations in config/push.local.json and switches between them
from the Destination list in Setup → Auto-push, without retyping:
{
"enabled": true,
"default_destination": "main",
"destinations": {
"main": {"host": "data.example.ca", "user": "example-data", "remote_base": "~/example-project/BIDS"},
"backup": {"host": "backup.example.ca", "user": "example-data", "remote_base": "/srv/mirror",
"port": 2222, "identity_file": "~/.ssh/id_backup"},
"alias": {"ssh_host": "my-lab-server", "remote_base": "~/example-project/BIDS"}
}
}
- A destination holds host, user, remote base, port and the key's path, never a key, password or passphrase. Any other field is refused when the destination is read, with a message that names the field and never its value. Save as… writes the fields below the list as a new destination.
ssh_hosttakes aHostalias from your~/.ssh/configinstead ofhost: ssh then supplies the account, port and key itself, sousermay be left out. The list also offers every concreteHostalias in~/.ssh/configthat is not yet saved; picking one saves it as a destination of that name (ssh_hostplus the Path field), so the choice survives a restart. That file is only ever read, never written; the toolbox takes the alias names from it and nothing else (not hosts, accounts or key paths), skips wildcard patterns, and does not followInclude: an alias defined only in an included file is not listed (the list's tooltip says so when the file hasIncludelines), but it still works if you save a destination with thatssh_hostby hand. Aconfigsaved as UTF-16 (Notepad's "Unicode") offers no aliases and the tooltip says why: ssh itself cannot read it either, so save it as UTF-8.- One default,
default_destination; it is what a push uses when nothing else picks. A machine with nodestinationsworks exactly as before: the file's top-levelhost/user/remote_base. - A study may name its destination. In the site profile, a study entry takes
"destination": "<name>"(a name only, so no host or account reaches the profile). Which destination a push uses is decided in this order: the operator's pick in the list, else the destination the study of the tree in Output dir names, else the machine'sdefault_destination, else the file's own top-level settings. Loading a protocol of such a study therefore preselects its destination, and one of a study naming none goes back to the default. The operator can still pick another: that pick holds for that study until Output dir moves to another study, and is never saved as the machine default. A pick made when no study destination is in force is the machine default, and is saved. The auto-push opt-in is saved whichever destination is selected. The study's folder under the destination's base is still derived from the tree, never typed, and the Goes to line shows the real<base>/<study>a push from Output dir will reach. - A destination that is missing or unusable is refused, never replaced by another server: a study naming a destination this machine has not saved, or a default that no longer resolves, pushes nothing and says which name is the problem.
- The different-origin collision check and the push report are per destination: each server is asked what it already holds, so a sub/ses that collides on one server is not held on another that has never seen it, and a report names only its own destination.
- One session may be mirrored to more than one destination (a main server and a backup, say), and
that is allowed. So each data root keeps a record of where its sessions went:
derivatives/ssvep-push/ledger.jsonl, one line per session per push, with the time (date-shifted like every other time in a data root), the saved destination's name (or "this machine's settings") and the number of files. It holds no host, account, path or key, is git-ignored with the rest ofderivatives/, and is never pushed or exported. Review / push… shows it next to each session ("pushed to: main, backup"). It is a record, not what decides what to push: the server is still asked what it holds, so a deleted ledger loses nothing, and a session copied up by hand is simply not in it. Every destination a session is pushed to must meet the data-residency rule in §6, and the data-use stamp is checked on each push.
2. Paths mirror 1:1 — there is no rearranging¶
Remote base is the BIDS root, not sourcedata/. A file's path relative to the local BIDS root
is its path relative to the remote base, verbatim:
local : BIDS/sourcedata/sub-XXX/ses-YYY/eeg/sub-XXX_ses-YYY_task-ssvep_run-001_eeg.xdf
remote: ~/example-project/BIDS/sourcedata/sub-XXX/ses-YYY/eeg/<same name>
└─── remote_base ───┘└──── identical to local ────┘
sourcedata/ appears on the server because it is in the local path, not because the push puts
it there. RunRecorder.output_base() writes into sourcedata/ directly, which is both correct
BIDS (a raw .xdf is source data, not a BIDS datatype — at the subject level it fails validation)
and what makes the mirror trivial to reason about: what you see locally is what lands remotely.
One folder per study (#396). When the site profile declares studies (docs/SITE_PROFILE.md §10a), each pushes into its own folder under the remote base, named by the study's id, and the rest of the path still mirrors 1:1:
The folder comes from the data root being pushed (its .ssvep-study.json marker, or the profile's study whose root it is), never from a setting typed on this machine, and it is applied on every route, the automatic ones included. The Review dialog and the status line name that folder, not the base above it. The first push into a study's folder also sends its marker, so the folder says which study it is on the server as on disk (it is never replaced once there). A root that holds another study's files is not pushed at all. With no studies declared, nothing changes: the remote base is the BIDS root.
Study ids are chosen per site profile, so two labs (or two PCs with different profiles) that both name a study pilot and push to one base share <base>/pilot/. The per-file origin check still refuses any file that would overwrite another machine's, but the two studies would then share one folder: give each deployment its own remote base, or ids that cannot collide.
Older trees with raw files at the subject level (BIDS/sub-XXX/...) are the pre-BIDS layout. Run
scripts/bids_convert.py (or io.bids.migrate_toplevel_to_sourcedata) once to relocate them; the
push only looks under sourcedata/ and will silently ignore anything still at the top level.
3. What gets pushed, and when¶
Research and pilot sessions: held until the session ends¶
Both automatic pushes below wait in a session whose data-use option can still be lowered (research and pilot; teaching is never pushed). The participant may lower their option, or switch to observer only, until the session is unlocked, and a file already on the server is one that change cannot reach. So while such a session is locked, its files are held: from the end-of-run push, the questionnaire push and Review / push… alike. Unlocking asks whether the session has ended and then pushes what was held; or it unlocks without pushing (to correct a detail and lock again), and the held files wait for Review / push…. The data-use gate (section 5) re-reads every stamp as they go, so a lowered option is what is checked. Bench sessions push as each run ends, exactly as described below. See the data-governance guide for lowering and deletion.
Automatically, at the end of a run¶
Only the run that just finished:
| File | Written by | Scope |
|---|---|---|
sub-XXX_ses-YYY_task-*_run-NNN_eeg.xdf |
the runner | per run |
…_eeg.session.json (manifest + provenance + data-use stamp + impedance) |
the runner | per run |
sub-XXX_ses-YYY_impedance.json |
Run Session's QA-check pane, on every Done (never for an observer-only session, a synthetic amp, or with no session locked) | per session |
The impedance log is per-session and is rewritten on every check (except in an observer-only session, on a synthetic amp, or with no session locked, which write none), so it rides along with each run — otherwise it would never be current on the server.
Automatically, the moment a questionnaire is saved¶
A break-time checkpoint or the end-of-session instrument has no run of its own to ride along
with — it can land between runs, or after the very last one — so the end-of-run push above cannot
see it. SessionRecordPanel.administer() pushes its own pair the instant save_response writes
them:
| File | Written by | Scope |
|---|---|---|
sub-XXX_ses-YYY_task-{postcondition,endofsession}_run-NNN_beh.tsv |
ssvep.runtime.questionnaire.save_response |
per administration |
…_beh.json (instrument + answers + flags + operator acknowledgement) |
save_response (+ record_acknowledgment, if a flag was acknowledged) |
per administration |
Before this existed, every questionnaire file needed a manual Review / push… — the run's own xdf/sidecar/impedance would mirror automatically while the beh files sat unpushed until someone noticed and pushed them by hand.
Everything else: Review / push…¶
Nothing else is ever sent automatically. A file being absent from the server is not evidence it belongs there — it may be a bench test, or a subject someone else is handling. So Review / push… lists the difference and lets you choose. It ticks only rows it is confident about, and each row shows its status:
| Status | Meaning | Ticked by default? |
|---|---|---|
new |
not on the server at all | ✅ yes |
changed |
on the server, a different size, and one of the files this session rewrites (the impedance log, a sidecar given notes or an acknowledgement, the declarations file) | ✅ yes |
same |
already there, byte-count matches | ❌ no — nothing to do |
Rows held back by their data-use stamp are shown greyed and cannot be ticked (§5), and so are rows refused because the server already holds a different origin's data at that subject/session (§4, "Two rigs or two studies on one code"); the Why not column says which. Every other missing row is ticked, whatever its subject code (§4).
Which files count as data¶
Explicitly listed per BIDS modality directory, not "everything in the folder":
| Modality | Patterns |
|---|---|
eeg/ |
*_eeg.xdf, *_eeg.session.json, *_impedance.json |
beh/ |
*.csv, *.log, *.psydat, *.tsv, *_beh.json |
stimuli/ |
<64 hex digits>/*: the pictures an image-target run showed, one directory per picture's sha256 (#258) |
declarations/ |
sub-*_ses-*_declarations.json: the participant's closed declarations (group pilot/test, a between-subject factor level), so a contrast run on the server reads the same ones as the PC they were made on (#276, BIDS.md) |
The pictures are stimulus assets, not participant data, but they travel with their session and under its data-use stamp like everything else here: a recording that cannot reproduce its own stimulus is the failure they exist to prevent. The end-of-run push sends the ones the run's sidecar lists (stimulus_files) with its XDF and sidecar. A stray file dropped straight into stimuli/ matches no pattern and is not sent.
A declarations file is offered in the review backlog like any other file the server is missing, under its session's data-use stamp: it carries no stamp of its own and the gate never reads it, so the declarations of a session that may not be pushed (a teaching one, or one recorded before the stamp existed) are held with the rest of it. An observer-only session saved nothing, so it has no declarations to push. The file only ever grows (each change appends), because this diff compares sizes, and an in-place edit of one level to another of the same length would otherwise read as already sent. It is also the one file that changes after it has been pushed, so the size comparison is read in one direction for it: when the server's copy is longer than this machine's, the server has declarations this machine lacks (declared there, or pushed from another PC), and the file is held, unticked, with that reason, rather than offered as "changed" and installed over the newer copy. Fetch the server's declarations onto this machine first (below), then declare again if anything is still to change. A same-size hand edit of the file is not detected at all: change declarations only with the command, which always appends.
Fetching the server's declarations back¶
python -m ssvep.runtime.fetch_declarations [--root DIR] [--destination NAME] [--sub sub-XXX ...] brings the declarations the study's destination holds back to this machine, for a session continued on another PC or after a re-install. It is the return trip for that one row and nothing else:
- Read-only, declarations only. It lists the study's folder (the listing a push diff uses) and reads only files whose path is exactly
sourcedata/sub-N/ses-X/declarations/sub-N_ses-X_declarations.json. No recording, questionnaire, impedance log or sidecar is requested, and nothing is written on the server.--subnarrows it to named participants, writtensub-103. - The study's own destination. The destination is chosen exactly as a push chooses it (
--destination, else the study's, else the default), into the study's folder, and refused where a push is refused. - Held to the local reading. Each file is read by the same strict reader as a local one (whatever that reader refuses, every format version it knows), checked in a scratch folder in the system temp rather than the data root. A file that fails is refused and reported, never partly used. What is installed is the server's own bytes, verbatim, so a push diff then sees the same size on both sides.
- Never replaces a newer file. The file only grows, so the server's copy is installed only when this machine's history is a prefix of it (or this machine has none). An equal or longer local copy is kept; so is one that has diverged from the server's (neither is replaced: compare them, then declare again here) and one that cannot be read (move it aside, then fetch again). A server copy in an older format version than this machine's is kept too (declare here, then push), as installing it would drop what the newer format holds. A session folder this machine lacks is skipped before anything is read from the server (a declaration travels with recordings), and a link is never written through. A file that cannot be written here (a read-only folder, say) is reported as kept, not as a crash. On Windows, two server sessions whose names differ only in case share one local folder; the second is reported as unreadable, and the advice to move the first aside is not what is wanted: fetch them one at a time.
- Reported. Each file is listed as fetched, kept, skipped or refused with its reason. A report names the saved destination and never a host, account or key path. The exit status is 0 when nothing was refused, 1 when something was, and 2 when the server could not be read.
A stray note or half-exported figure in an eeg/ folder is not data and is not sent. Adding a
new modality means adding a row to push.MODALITY_PATTERNS — until you do, its files are silently
skipped. Add the row in the same change that starts writing them.
4. The server is the source of truth¶
The toolbox asks the server what it already has (one ssh … find round-trip) and diffs against
local. It keeps no local ledger of what it has pushed.
This is deliberate, and it is a correction. A ledger cannot know about files an operator copied up by hand — which is exactly how early bench-test pilots got there, over WebDAV from the Synology. A ledger-based version reported a 37-file "backlog" that was already on the server in full. Ask the server; it knows.
Consequences worth knowing:
- Comparison is name + size, not a checksum. An XDF is large and the point is to catch a file
that was re-recorded, not to defend against tampering. Where the server already holds a session,
its sidecars are also read back to check whose data it is (below).
- If the server is unreachable, you get an error, not an empty list. We don't know what's up
there, so we must not present a backlog we can't vouch for.
- A missing remote base is not an error — it means nothing has been pushed yet, so everything
reads as new.
- An unreadable base is an error — and it is not the same thing as a missing one, even though
find exits 1 for both. If the account cannot enter the remote directory, we do not know what is
up there, so the scan refuses rather than showing a backlog. Only "No such file or directory"
counts as an empty server. This was a real failure (#76): the index command ended in
2>/dev/null || true, which discarded the difference and forced exit 0, so a project folder the
push account had no permission on looked exactly like a fresh server — every file reported new,
the dialog looked healthy, and no push could ever have succeeded. If you are diffing against a
directory somebody else owns, this is the message you want to see.
- The NAS scatters @eaDir/ and ._DAV/ sidecar directories through the tree; those are pruned
from the index and never mirrored.
Two rigs or two studies on one code: refused, never overwritten¶
Paths mirror 1:1 under one remote base, so two acquisition machines, or two studies on one machine, or two participants given the same code on one machine, address the same folder on the server. Push therefore checks whose data is already there before writing anything, on every route: the end-of-run push, the questionnaire push, the pushes held until a session ends, and Review / push…. It reads back the server's copies of the files that say whose data a session folder holds (each run's *.session.json and each questionnaire's *_beh.json) and compares them with this machine's, using only what the recordings already carry:
- the protocol fingerprint in
session_position; - the purpose in the data-use stamp;
- the machine the run was recorded on, from the LSL stream identity each sidecar records (
lsl_streams), compared the way stream binding compares hosts: the short name, ignoring case and any DNS domain suffix; - the recording itself: its random LSL stream
uids when the sidecar records the markers stream (a new outlet every run), or otherwise a hash of the sidecar without the parts the same session may change later (the data-use stamp, notes added after the run, a flag acknowledgement). The EEG stream'suidalone is not used: an amplifier's outlet can persist across runs.
A server sidecar that is the very recording one of this machine's sidecars describes shows the folder is this session's.
What it refuses:
On the server at this sub/ses |
Result |
|---|---|
| a sidecar naming a different protocol or purpose, or recorded on a different machine | the whole session is refused: even a new file would merge two origins' data in one folder |
| a sidecar that records the machine it was made on where this machine's copies do not, or the other way round (a sidecar from before stream identities were recorded, or a run that bound no stream), and is not one of this machine's own recordings | the whole session is refused: the machines cannot be compared, so it is not shown to be this machine's. When neither side records a machine, only the recording identities are left to compare |
| a sidecar that cannot be read, or an XDF with no sidecar that this machine does not hold byte for byte | the whole session is refused: it cannot say whose data it is |
| a sidecar at the same path describing a different recording, and no sidecar there that is this machine's | the whole session is refused: this is what a second participant under a reused code looks like, and it cannot be told from a re-recorded run |
| a sidecar at the same path describing a different recording, beside one that is this machine's (a run re-recorded after it was pushed) | that run's sidecar and XDF are refused; the rest of the session still goes. The server's copy may be the only one of the first recording left |
a file written only once (XDF, _beh.tsv, a picture) of a different size |
refused, and an XDF or _beh.tsv takes its sidecar with it: a recording and its sidecar are accepted or refused together, so the server never holds one recording beside another's sidecar |
| a declarations file longer than this machine's | refused (§3) |
| an impedance log or declarations file, when the server holds sidecars there and none is this machine's | refused: only this session may replace its own rewritten files |
| the same files, or this session's own rewritten files (impedance log, notes, acknowledgement) | pushed; an identical re-push changes nothing |
A refused file is reported with the reason and what to do. It is not a failure to retry: the same push is refused again until the clash is resolved. A different participant or a different study needs its own subject or session code; keeping each study in its own data root (#396) prevents the clash, and a session deliberately continued on a second machine is a case for #396 too. If both copies should be the same recording, compare them by hand. If the server cannot be read for this check, nothing is sent.
The machine comparison adds no identifying data: the hostname it compares is one every pushed sidecar already carries in lsl_streams (and the XDF's stream headers carry it too). The check itself only hashes it, in memory; it writes no hostname of its own anywhere, and puts none in a message.
The check and the write are separate steps, so a second machine could write a file in between. A file the check found absent is renamed into place with the protocol's own rename, which never replaces an existing file, so in that case the late file fails, the other machine's copy stays, and the failed batch removes its own temp files. A file at a new path written into the folder in that window is not caught. Push appends what it sent to the root's push ledger (#438), which Set up Session also reads so a code or session that now lives only on the server is not offered again.
sub-000 is not special¶
It used to be: sub-000 was the Set up Session tab's pre-filled subject, so a recording under it was
taken to mean nobody had set the subject (a bench test), and it was never ticked by default. A
session now says what it is in its own data-use stamp (a bench session's purpose is bench, #286),
so the subject code carries no meaning, the Subject field starts blank, and the lock refuses until
one is entered. Under option 0 (observer only) the code is fixed to sub-000 because nothing is written (#141); that is a consequence of writing nothing, not a meaning.
5. Data-use gating (COMPLIANCE R5)¶
Every file a session writes carries its data-use stamp (data_use: the session's purpose and
the participant's tier, set at lock, #286), and the push reads it back through the one matrix every
gate shares (ssvep.runtime.data_use.MATRIX; docs/DATA_GOVERNANCE.md). Three things enforce it:
- Observer-only sessions never reach the disk at all — under tier 0 nothing is written, not even temporarily, so there is nothing for the push to find.
- The push re-reads the stamp off each sidecar anyway, rather than trusting the caller. This matters for a file pushed days later by someone who never saw the session locked.
- The gate applies to the automatic end-of-run path too, not just the dialog — that path runs with nobody looking at it, so it must not depend on a human noticing.
A session is pushed only when every *.session.json in its eeg/ and every *_beh.json in its
beh/ allows it:
| Situation | Pushed? |
|---|---|
a research stamp at tier 1, 2 or 3 |
✅ yes |
a pilot or bench stamp |
✅ yes — the lab's own storage |
a teaching stamp |
❌ never — a class's data stays on the course machine |
| any sidecar in the session that does not allow it | ❌ the whole session is held back |
no stamp: only the older three-flag consent record, or nothing |
❌ held back — there is no migration |
| a stamp that cannot be read (unknown key, wrong JSON type, tier 0), or a stamp and a legacy record together | ❌ held back — never a grant |
| no run sidecar (a declarations-only or impedance-only session) | ❌ held back — data use unknown |
| a sidecar that is corrupt or cannot be read | ❌ held back, and named as corrupt — data use unknown. A sidecar saved with a UTF-8 byte-order mark or Windows line endings is read normally |
Two practical consequences of that last row. An impedance check with no session after it has no
run sidecar, so it is never pushed, though a contact-quality record from an abandoned setup then
stays on the acquisition PC. And legacy recordings (no *.session.json, or one written before
the stamp existed) can't be pushed by the toolbox at all; they must be placed by hand, as they were.
Only those two sidecars have a say. Every other file the push mirrors (the XDF, the impedance
log, a _beh.tsv, a stimulus picture) travels or stays under its session's verdict. A file no row
of MODALITY_PATTERNS names, JSON or not, is skipped, not refused: never mirrored and never
read, so an unstamped one neither leaves nor holds its session back. A new kind of per-session file
that should travel gets a row there, which puts it under the session's verdict; if it should carry a
stamp of its own that can veto the session, the gate's read list needs its glob too.
Tier 2 and 3 do not add anything here. They govern sharing — reuse beyond the study, or a
public release, which is export's question (docs/EXPORT.md). Pushing to your deployment's own server
is internal storage, i.e. the sanctioned local → server → compute chain of R1, not a release.
6. When it fails¶
Nothing is lost, and nothing is silent. The push is a copy of files already safely on local disk, so a failure is a delay, not a hole. The Run Session tab shows a red warning:
⚠ push: 3 failed — data.example.ca is not answering on port 22 — server down, or VPN needed.
Data is safe on this machine — use Review / push… to retry.
Retry with Review / push… when the server is back; the failed files still show as new, because
the server is what's being asked.
| Message | Meaning | Fix |
|---|---|---|
| host key not trusted | this machine has never talked to the server | run ssh <user>@<host> once and accept the key (§1.3) |
| server refused the key | your public key isn't on the account | get it added to ~/.ssh/authorized_keys |
cannot read <path> … no permission on the directory |
the key works and you are logged in, but the push account cannot enter the remote project folder | whoever owns it must grant that account access — not an SSH problem, so don't touch authorized_keys |
| cannot resolve … | DNS / not on the network | connect to the network or VPN |
| … is not answering on port 22 | server down, or firewall/VPN | check the server; retry |
| OpenSSH client not found | Windows OpenSSH feature missing | Settings → Optional features → OpenSSH Client |
Each file uploads to a .part-<random> name, unique to that push so two machines never share a temp file, and is renamed into place, so an interrupted transfer leaves visible debris rather than a truncated file that looks like a complete recording. A batch that fails once the server has accepted the connection removes its own temp files (the name is that push's alone, so it never touches another machine's); a transfer cut off with the connection itself leaves its temp file behind, under a name nothing reads as a recording. Nothing is deleted first, so there is never a moment with no file at the destination: a new file is renamed with the rename that refuses to replace, and only a file this session already put there (§4) is replaced, atomically. A server whose SFTP does not offer OpenSSH's atomic rename cannot be updated in place, and that file fails rather than being deleted and re-sent. If a batch fails
but the server is reachable, the toolbox retries file-by-file so one bad file cannot hold the rest
behind it — and if the server is unreachable it skips that fallback, so a down host costs one
timeout rather than N.
7. Compliance notes¶
Residency (R1/R3). The push destination must satisfy your deployment's data-residency rule, and the host it names is a site-profile setting rather than something this page can state. For the deployment this toolbox was built for, that rule is the data stays in Canada — pointing the push at a foreign/US cloud host (S3, Dropbox, Drive, …) violates it, regardless of encryption. Nothing in the toolbox can check where a hostname resolves; that is on whoever configures it.
Transport (R4) — a noted divergence. R4 enumerates the server's approved encrypted external access as HTTPS/AFP/WebDAV; SFTP is not in that list. It is encrypted, key-authenticated, and sits on top of the server's existing firewall/IP controls rather than around them — arguably equivalent-or-stronger — but it is a protocol the written controls do not name. A deployment whose approved controls do not name SFTP must have it approved before use; it is not collection-blocking (it moves already-collected data by an alternative route to the same destination), but the wording should be confirmed.
Why the system OpenSSH client, not paramiko. Windows ships sftp.exe in System32\OpenSSH, so
shelling out to it adds no dependency and no license review, and it reuses the operator's existing
key, ~/.ssh/config, and known_hosts — the same credentials that already work when they type
ssh by hand. paramiko would have been licence-compatible (LGPL-2.1+) but pulls in a crypto stack
for no gain here.
No PII. Only de-identified sub-XXX paths ever travel (R2), and the config file holds no
secret — just a hostname, an account name, and a path.
A held push is not a successful one¶
Every file held by the data-use gate is listed in the push result with its reason, in the run tab and in Review / push… (greyed, with the reason in Why not). A push where anything the operator asked for was held back or refused reports a warning, not success, even if other files went through, and a push where everything was held says so rather than "nothing to push". The gate itself is unchanged: this is about reporting.