Arming and the kill switch
Two interlocks stand between a logged command and your vehicle. Both fail closed.
Logging a command is always safe — it writes an audit row and annotates a chart. Dispatching one causes an HTTPS POST to a system that may act on it, so two things must be true first.
| Interlock | Scope | Default |
|---|---|---|
XPECTRA_COMMAND_WEBHOOKS_ENABLED | The whole deployment | off |
| Dataset arming | One dataset, with a TTL | not armed |
Both fail closed. A deployment that has not thought about command delivery does not do it.
The kill switch
An environment variable on the XpectraFlow deployment. With it off:
POST /api/commands/dispatchreturns409and nothing is queued- the dispatcher does not deliver what is already queued
It is read on every dispatch, not once at startup. Turning it off stops new dispatches immediately, with no restart. A kill switch that needs a rolling deploy to take effect is the wrong latency for the situation a kill switch exists for.
Turning it off does not recall commands already handed to the queue. To stop those, restart the dispatcher as well. If a vehicle is doing something unexpected, stop the vehicle — the fastest control here is still your own receiver.
Self-hosting? It is XPECTRA_COMMAND_WEBHOOKS_ENABLED on both xpectra-web and
xpectra-commanddispatcher, and both must agree.
Arming a dataset
/api/datasets/armscope commands:writeProp
Type
curl -s -X POST https://app.xpectraflow.com/api/datasets/arm \
-H "x-api-key: $XPECTRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"experimentId": "'"$EXPERIMENT_ID"'",
"datasetId": "'"$DATASET_ID"'",
"ttlSeconds": 900
}'{
"datasetId": "3d81f0a2-…",
"armed": true,
"armedAt": "2026-07-31T09:14:02.310Z",
"expiresAt": "2026-07-31T09:29:02.310Z"
}Why there is a ceiling
An hour, maximum. That is deliberate friction: an interlock you can switch on and forget is one that is always on by the second week, and then it is not an interlock.
Re-arm when the window lapses. It puts a person back in the loop at a predictable interval, during a sortie, which is when you want them there.
Why it is a table, not a flag
Every arming is a row: who armed it, when, for how long, and whether it was revoked early. Re-arming writes a new row rather than extending the old one.
A review after a bad day asks "was this dataset armed at 14:32, and who armed it". A mutable boolean answers neither.
Checking
/api/datasets/armingscope commands:readcurl -s "https://app.xpectraflow.com/api/datasets/arming?experimentId=$EXPERIMENT_ID&datasetId=$DATASET_ID" \
-H "x-api-key: $XPECTRA_API_KEY"{
"datasetId": "3d81f0a2-…",
"armed": true,
"armedAt": "2026-07-31T09:14:02.310Z",
"expiresAt": "2026-07-31T09:29:02.310Z",
"secondsRemaining": 612
}secondsRemaining exists so a ground station can show a countdown and re-arm
before the window closes — rather than discovering it lapsed by having a
dispatch refused mid-sortie.
Disarming
/api/datasets/disarmscope commands:writecurl -s -X POST https://app.xpectraflow.com/api/datasets/disarm \
-H "x-api-key: $XPECTRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"experimentId": "'"$EXPERIMENT_ID"'",
"datasetId": "'"$DATASET_ID"'",
"reason": "Sortie complete"
}'Revokes every live arming for the dataset, not just the most recent — two overlapping arms would otherwise leave it armed after an explicit disarm, and this is the one direction that must never fail quietly.
Disarming something already disarmed succeeds. You asked for a state and the state holds; erroring would make the safe reflex — hit disarm, then check — look like a fault.
Disarming stops new dispatches. Commands already queued are already on their
way. The distinction matters during an abort: disarm prevents anything further
being sent, and your receiver is what decides whether to act on something
already delivered — which is why the payload carries arming.expiresAt, so you
can drop a command whose window has closed.
What a refusal looks like
{
"error": {
"code": "conflict",
"message": "This dataset is not armed for command delivery. Arm it first with POST /api/datasets/arm.",
"details": [],
"request_id": "31592257-…"
}
}The command stays logged. It is not marked failed, because nothing was
attempted — and the audit row should say what happened, which is nothing.
Arm the dataset and dispatch again; the same command id works.