Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes

- Pick a missing organization, environment, or wallet from a list on a terminal.
- `voltage context` shows the effective profile, scope, credential, API and auth URLs, and where each came from.
- `--dry-run` describes any API request offline; changes are only described unless `--execute` or `VOLTAGE_EXECUTE=1` asks to send them.
- Changes are only described (exit code 6) unless `--execute` or `VOLTAGE_EXECUTE=1` asks to send them; the description is built offline and sends nothing.
- `--no-input` and `--quiet`, with interrupt-safe progress and the time waited so far on payment waits and device approval.
- `voltage --version` shows the git build.
- Every command that changes something has examples in `--help`, and every command links its guide.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The invoice prints as a QR code as soon as it is ready. Leave out `--execute` to
```sh
voltage payments list --profile prod --json | jq '.data.items[].status'
voltage payments send --profile prod --wallet WALLET_ID \
--currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --dry-run
--currency btc --invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats # exits 6: described, not sent
VOLTAGE_EXECUTE=1 ./pay-invoices.sh
```

Expand All @@ -77,7 +77,7 @@ All codes are in the [exit code reference](docs/reference/exit-codes.md).
| [Requests](docs/guides/requests.md) | Request bodies, filters, paging |
| [Checkout](docs/guides/checkout.md) | Session and stream tokens, events |
| [Scripting](docs/guides/scripting.md) | JSON, `--execute` in scripts, quiet output |
| [Safety model](docs/concepts/safety-model.md) | Describe-by-default, dry runs, payment IDs, Ctrl-C, redaction |
| [Safety model](docs/concepts/safety-model.md) | Describe-by-default, payment IDs, Ctrl-C, redaction |
| [Output reference](docs/reference/output.md) | Tables, the JSON envelope, streams |
| [Command reference](docs/commands.md) | Every command and its API route |

Expand Down
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ Start with the [README](../README.md) for install and a quick start, or follow [

## Understand

- [Safety model](concepts/safety-model.md): `--execute`, dry runs, payment IDs, Ctrl-C, redaction
- [Safety model](concepts/safety-model.md): `--execute`, described changes, payment IDs, Ctrl-C, redaction

## For AI agents

Use `--json` and branch on exit codes. Describe a change with `--dry-run` before you send it with `--execute`. Never pass `--show-secrets`. For product concepts, see the [Voltage API docs](https://voltageapi.com/v1/docs).
Use `--json` and branch on exit codes. Run a change without `--execute` to describe it (exit code 6), then send it with `--execute`. Never pass `--show-secrets`. For product concepts, see the [Voltage API docs](https://voltageapi.com/v1/docs).

## For contributors

Expand Down
26 changes: 14 additions & 12 deletions docs/concepts/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,27 @@

## Changes need `--execute`

Commands that change something (any method but GET) only describe their request unless you pass `--execute`, then exit with code 6. Reads run as usual. `--execute` is the only approval: nothing asks again before sending. A described change reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. Scripts can set `VOLTAGE_EXECUTE=1` instead; see [scripting](../guides/scripting.md#send-changes-from-a-script).
Commands that change something (any method but GET) only describe their request unless you pass `--execute`. Reads run as usual. `--execute` is the only approval: nothing asks again before sending.

## Dry runs
| Command | What happens | Exit code |
|---|---|---|
| A read (`list`, `get`, `watch`) | Sends the request | 0 on success |
| A change without `--execute` | Describes the request and sends nothing | 6 |
| A change with `--execute` | Sends the request | 0 on success |

`-n, --dry-run` validates any API command and describes the request it would send, then stops: it reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next. That is the same plan a real run follows:

- A `read` says whether it checks the wallet's environment and whether it then pages, waits for a payment, or waits for a checkout session.
- A `mutation` says whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for.
- An `event_stream` follows checkout events.

A dry run authenticates nothing, makes no network request (including the wallet environment check a real mutation performs), prompts for nothing, writes no journal entry, and does not create `--output-file`. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.
Exit code 6 means "described, not sent", so a script that forgot `--execute` fails instead of looking as if it sent money. To check a change on purpose, run it without `--execute` and treat exit code 6 as the expected result. Scripts can set `VOLTAGE_EXECUTE=1` instead of passing `--execute`, and `VOLTAGE_EXECUTE=0` describes one command again; see [scripting](../guides/scripting.md#send-changes-from-a-script).

```sh
voltage payments send --profile prod --wallet WALLET_ID --currency btc \
--invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats --dry-run
voltage wallets delete WALLET_ID --profile prod -n --json
--invoice BOLT11_INVOICE --max-fee 10 --fee-unit sats
voltage wallets delete WALLET_ID --profile prod
```

The result is a normal envelope with `outcome: dry_run` and no `http_status`.
## What a described change shows

The description reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next: whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for. It reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. The result is a normal envelope with `outcome: dry_run` and no `http_status`.

Describing a change authenticates nothing, makes no request for the change itself (including the wallet environment check a real run performs), writes no journal entry, and does not create `--output-file`. On a terminal, a missing organization, environment, or wallet can still open a [picker](../guides/login-and-profiles.md#pick-the-scope-interactively), which lists choices from the API; pass the scope, `--no-input`, or `--json` to stay offline. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.

## Payment IDs and the request journal

Expand Down
9 changes: 5 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ python3 scripts/check-coverage.py
Integration tests start mock HTTP servers on port 0 and never contact a real service. They
cover every operation in the contract, query encoding, complete JSON bodies, credential
selection, describe-by-default changes, secret output, pagination, payment waits, uncertain submissions,
checkout tokens and streams, and the credential lifecycle. Every change the docs show with
`--execute` also runs as an offline `--dry-run`, with stub bodies for `--data` files, so
documented flags, units, scope, and request bodies keep validating. Live payment tests must
use an explicitly configured test-network wallet; routine tests never send a live payment.
checkout tokens and streams, and the credential lifecycle. Every change the docs or `--help`
examples show with `--execute` also runs offline without `--execute`, with stub bodies for
`--data` files, and must be described (exit code 6), so documented flags, units, scope, and
request bodies keep validating. Live payment tests must use an explicitly configured
test-network wallet; routine tests never send a live payment.

`docs/reference/global-flags.md` is rendered from the command definitions; after changing a global flag, run `VOLTAGE_BLESS=1 cargo test` to regenerate it, since plain `cargo test` fails on a stale copy. Tests also require every `VOLTAGE_*` variable read in `src/` to appear in `docs/reference/environment.md`, and every exit code in `docs/reference/exit-codes.md`.

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ voltage wallets list --profile test

Use a wallet on a BTC line of credit. A wallet on a USD line of credit needs a quote for every payment, so step 4 would fail with [`quote_required`](guides/troubleshooting.md#quote_required).

If there is no such wallet, create one on a BTC line of credit from `credit-lines list`. Check the request with a dry run first, then send it with `--execute`:
If there is no such wallet, create one on a BTC line of credit from `credit-lines list`. Run it without `--execute` first to see the request, then send it with `--execute`:

```sh
voltage credit-lines list --profile test
voltage wallets create --profile test --name tutorial \
--credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --dry-run
--credit-line CREDIT_LINE_ID --network mutinynet --limit 100000
voltage wallets create --profile test --name tutorial \
--credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --execute
```
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ voltage payments receive --currency btc --kind bolt11 --amount 1000 --unit sats

The picker offers every wallet in the environment. For this example, pick a wallet on a BTC line of credit. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`.

An API key cannot list organizations or environments, so with a key the CLI offers only wallets; pass `--org` and `--env` or use a profile. Esc leaves the scope unchanged, and the command then reports what is missing. Ctrl-C exits with code 130. Picking a scope never sends a change: changes still need `--execute`. The CLI never asks when stdin or stderr is not a terminal, when stdout is piped without `--output table` (as in `voltage wallets list | jq`), or with `--no-input`, `--json`, `--output json|ndjson`, or `--dry-run`. In those cases, the command fails with the same error as before.
An API key cannot list organizations or environments, so with a key the CLI offers only wallets; pass `--org` and `--env` or use a profile. Esc leaves the scope unchanged, and the command then reports what is missing. Ctrl-C exits with code 130. Picking a scope never sends a change: changes still need `--execute`. The CLI never asks when stdin or stderr is not a terminal, when stdout is piped without `--output table` (as in `voltage wallets list | jq`), or with `--no-input`, `--json`, or `--output json|ndjson`. In those cases, the command fails with the same error as before.

## Profiles

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/wallets.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ voltage wallets policies get WALLET_ID --profile prod --json | jq .data > polici
voltage wallets policies update WALLET_ID --profile prod --data @policies.json --execute
```

Policies have no friendly flags: save the current policies, edit the file, and send the complete object with `--data`. Save only `.data`: `--json` and `--output-file` wrap the response in an envelope, which is not a policy body. Deleting a wallet cannot be undone, so check the request with a dry run first:
Policies have no friendly flags: save the current policies, edit the file, and send the complete object with `--data`. Save only `.data`: `--json` and `--output-file` wrap the response in an envelope, which is not a policy body. Deleting a wallet cannot be undone, so run it without `--execute` first to see the request:

```sh
voltage wallets delete WALLET_ID --profile prod --dry-run
voltage wallets delete WALLET_ID --profile prod
voltage wallets delete WALLET_ID --profile prod --execute
```

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL
| `VOLTAGE_ORGANIZATION_ID` | Organization UUID | `--org`, `--profile` |
| `VOLTAGE_ENVIRONMENT_ID` | Environment UUID | `--env`, `--profile` |
| `VOLTAGE_WALLET_ID` | Wallet UUID | `--wallet`, `--profile` |
| `VOLTAGE_EXECUTE` | `1` or `true` sends every change without `--execute`; `0`, `false`, or empty only describes changes. Any other value fails before a change. | `--execute`, `--dry-run` |
| `VOLTAGE_EXECUTE` | `1` or `true` sends every change without `--execute`; `0`, `false`, or empty only describes changes. Any other value fails before a change. | `--execute` |
| `VOLTAGE_CHECKOUT_TOKEN` | A checkout session token for `checkout sessions` reads | `--token-file` |
| `VOLTAGE_STREAM_TOKEN` | A checkout stream token for `checkout events watch` | `--token-file` |
| `VOLTAGE_CONFIG_DIR` | The configuration directory, as an absolute path | `--config-dir` |
Expand Down
17 changes: 3 additions & 14 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,12 +523,6 @@ pub async fn execute(
check_invoice_flags(invocation, &request)?;
let run = Run::plan(invocation, &request, scope);
match execution {
Execution::DryRun => {
global
.terminal()
.notice("Dry run: nothing was authenticated, confirmed, recorded, or sent.");
return out.write(dry_run::describe(invocation, global, &request, run)?, &[]);
}
Execution::DescribeChange => {
out.write(dry_run::describe(invocation, global, &request, run)?, &[])?;
return Err(Error::new(
Expand Down Expand Up @@ -592,8 +586,6 @@ pub async fn wallets(
/// Whether an API command sends its request, and what decided it.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Execution {
/// `--dry-run`: describe the command and succeed.
DryRun,
/// A change with neither `--execute` nor `VOLTAGE_EXECUTE`: describe it, then fail, so
/// a script that forgot `--execute` does not pass silently.
DescribeChange,
Expand All @@ -608,7 +600,6 @@ impl Execution {
/// changes are described.
pub fn resolve(invocation: &ApiInvocation, execute_variable: Option<bool>) -> Self {
match invocation.requested {
Requested::DryRun => Self::DryRun,
Requested::Execute => Self::Send,
Requested::Unspecified if invocation.operation.method == Method::Get => Self::Send,
Requested::Unspecified if execute_variable == Some(true) => Self::SendFromEnvironment,
Expand All @@ -618,15 +609,15 @@ impl Execution {

/// The command describes its request instead of sending it.
pub fn describes(self) -> bool {
matches!(self, Self::DryRun | Self::DescribeChange)
self == Self::DescribeChange
}
}

pub const OFFSET_PAGINATION_DEPRECATED: &str =
"Offset pagination is deprecated by the API; omit --offset and --pagination to page by cursor.";

/// What a validated API command does, decided once after local validation. `execute`
/// follows it and `--dry-run` reports it, so the two cannot disagree.
/// follows it and a described change reports it, so the two cannot disagree.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Run {
Expand Down Expand Up @@ -1850,17 +1841,15 @@ mod tests {
fn the_flag_decides_then_voltage_execute_then_reads_send_and_changes_are_described() {
let resolve = |args: &[&str], variable| Execution::resolve(&invocation(args), variable);
let change = ["wallets", "delete", WALLET];
let dry_run = ["wallets", "delete", WALLET, "--dry-run"];
let execute = ["wallets", "delete", WALLET, "--execute"];
for variable in [None, Some(false), Some(true)] {
assert_eq!(resolve(&dry_run, variable), Execution::DryRun);
assert_eq!(resolve(&execute, variable), Execution::Send);
assert_eq!(resolve(&["wallets", "list"], variable), Execution::Send);
}
assert_eq!(resolve(&change, None), Execution::DescribeChange);
assert_eq!(resolve(&change, Some(false)), Execution::DescribeChange);
assert_eq!(resolve(&change, Some(true)), Execution::SendFromEnvironment);
assert!(Execution::DescribeChange.describes() && Execution::DryRun.describes());
assert!(Execution::DescribeChange.describes());
assert!(!Execution::SendFromEnvironment.describes());
}

Expand Down
Loading
Loading