# Configuration

The config lives at `~/.config/sa-forge-signer/config.toml` unless `--config` or `SA_FORGE_SIGNER_CONFIG` says otherwise. `sa-forge-signer config init` writes the example below there (mode 0600, in a 0700 directory it creates if needed) and refuses to overwrite an existing file. It has no keys yet: add the block `key new` prints for each key, and `[serve]` if you run `serve`.

`sa-forge-signer config show` prints the config as the signer resolves it: the paths, the RPC, and the instruction table if one is set, each key with its effective class, approval, limits, policy digest and whether its key file exists, defaults filled in, then `[serve]` with each token's name and keys. It reads no key material, prints no token hashes, and calls no RPC.

## Settings

Only `class` and `pubkey` on each key, and a token for `serve`, have to be set; everything else has a default.

| Setting | Default | Set it when |
|---|---|---|
| `cluster` | `"zink-testnet"` | never: it is the only cluster |
| `rpc_url` | the cluster's public RPC (`https://rpc1.z.ink`) | you use another RPC you trust ([Security](security.md)). It must be `https://`; `http://` loads only for a loopback host (`127.0.0.1`, `::1`, `localhost`), such as a local validator, so signed transactions and any API key in the URL never cross a network in clear |
| `explorer_url` | the cluster's explorer (`https://explorer.z.ink`) | you prefer another block explorer. Used only for links: the `explorer` field of a `sign` report and the [console](apps/console.md)'s links; the signer never calls it |
| `key_dir` | `keys`, beside the config | keys live elsewhere (in Docker, `/run/keys`) |
| `state_dir` | `state`, beside the config | the audit log lives elsewhere (in Docker, `/var/lib/sa-forge-signer`) |
| `instruction_table` | none | you want the [instruction table](apps/idl-table.md)'s findings: `{ path = "...", sha256 = "...", mode = "report" }`, the path relative to the config. The signer refuses to start if the file's sha256 differs or it is for another cluster. `report` adds `findings` to each report and audit entry and never refuses; an enforcing mode comes later |
| `[keys.<name>] class` | required | `"session"` for a SAGE-scoped profile key, `"wallet"` for the profile authority |
| `pubkey` | required | the pubkey `key new` or `key import` printed |
| `profile` | none | you want `key show` and the MCP `key_show` tool to report the key's profile, or you configure an `instruction_table`: its `profile` finding holds the key to this profile |
| `approval` | `"auto"` for a session key, `"confirm"` for a wallet key | a key should never sign (`"deny"`), or a testnet wallet should sign unattended (`"auto"`, with `allow_unattended`) |
| `allow_unattended` | `false` | a throwaway testnet wallet key must be `"auto"` or on a serve token |
| `rate_limit_per_minute` | `30` | the agent signs faster than that |
| `daily_lamport_cap` | `50000000` (0.05 ZINK) | the key spends more in 24 h (fees and transfers it signs) |
| `halt_after_unknown` | `3` | the key should [halt](check-and-sign.md#halting) after more or fewer consecutive `unknown` outcomes; `0` never halts it |
| `require_policy_approval` | `false` | a change to the key's policy should stop it signing until a person runs `key approve <name>` at a terminal ([Policy digest](check-and-sign.md#policy-digest)) |
| `max_compute_units` | `1400000` (the runtime's maximum) | the key's payloads should be held to a lower `SetComputeUnitLimit` |
| `max_compute_unit_price` | `0` (no priority fee) | the key may pay a priority fee: the highest `SetComputeUnitPrice`, in micro-lamports per compute unit |
| `transfer_max_lamports` | none (only the daily cap) | the key's System transfers, summed over one transaction, should stay under a smaller amount |
| `transfer_to` | none | the key funds other accounts with ZINK, such as a wallet funding its session key or vault |
| `extra_programs` | none | the key must call a program outside the built-in list ([How it works](how-it-works.md)); add only programs you trust |
| `[serve] listen` | `"127.0.0.1:8790"` | `serve` should listen elsewhere (in Docker, `"0.0.0.0:8790"`) |
| `allowed_hosts` | `["127.0.0.1", "localhost"]` | clients reach `serve` under another host name |
| `[[serve.tokens]]` | none: `serve` will not start | always, for `serve`: `name`, the `sha256` `token new` printed, and the `keys` it may use |

The example, as `config init` writes it:

```toml
# sa-forge-signer config. Default location: ~/.config/sa-forge-signer/config.toml
# Relative paths resolve against this file's directory.

cluster = "zink-testnet"
# rpc_url = "https://rpc1.z.ink"   # default for the cluster; https only (http only on loopback)
# explorer_url = "https://explorer.z.ink"   # default for the cluster; for links only
# key_dir = "keys"                 # 0700; one 0600 file per key
# state_dir = "state"              # audit log
# Report what the instruction table (just apps idl-table) says about each payload; never refuses.
# instruction_table = { path = "zink-testnet.json", sha256 = "SHA256_OF_THE_FILE", mode = "report" }

# A SAGE-scoped session key. Signs without asking. The config holds no key index: the agent passes
# the key's profile index (the next free one when granted, not always 1) as forge-mcp's keyIndex.
# Paste the block `sa-forge-signer key new <name> --class session` prints, or uncomment and fill in.
# [keys.alice-session]
# class = "session"
# pubkey = "PUBKEY_FROM_key_new"
# profile = "PROFILE_ADDRESS"
# rate_limit_per_minute = 30
# daily_lamport_cap = 50000000     # 0.05 ZINK
# halt_after_unknown = 3           # halt the key after this many unknown outcomes in a row; 0 never
# require_policy_approval = false  # sign only while the policy digest is the one `key approve` accepted
# max_compute_units = 1400000      # highest SetComputeUnitLimit
# max_compute_unit_price = 0       # highest SetComputeUnitPrice (micro-lamports); 0: no priority fee
# extra_programs = []               # more programs this key may call at the top level. They are
#                                   # NOT trusted as game programs: token accounts can't be created
#                                   # for accounts they own. Add only programs you trust.

# Path A only: the profile authority and asset wallet held by the agent.
# Defaults to approval = "confirm": each signature is approved on the terminal.
# A wallet key cannot be "auto" or listed on a serve token: it signs only through the CLI
# with a person at the terminal. allow_unattended = true lifts both rules; use it only for a
# throwaway testnet key, since the signer does not decode what a profile grant gives away.
# [keys.alice-wallet]
# class = "wallet"
# pubkey = "WALLET_PUBKEY"
# transfer_to = ["PROFILE_ADDRESS", "SESSION_KEY_PUBKEY"]  # fund the vault and the session key
# transfer_max_lamports = 100000000                         # all transfers in one transaction

# `sa-forge-signer-serve`: HTTP at /v1/check and /v1/sign, MCP at /mcp.
# [serve]
# listen = "127.0.0.1:8790"
# allowed_hosts = ["127.0.0.1", "localhost"]
# [[serve.tokens]]                  # from `sa-forge-signer token new <name> --out <file>`
# name = "agent"
# sha256 = "HASH_PRINTED_BY_token_new"
# keys = ["alice-session"]
```

## Tracing

Tracing is off by default. Set `SA_FORGE_SIGNER_LOG` to `error`, `warn`, `info`, `debug` or `trace` to write spans and events to stderr (stdout still carries only the JSON report), and `SA_FORGE_SIGNER_LOG_FORMAT=json` for one JSON object per line instead of the compact text format. An unknown value stops the signer with an error rather than being ignored. Each span logs its duration (`time.busy`, `time.idle`) when it closes:

| Span | Level | What it times |
|---|---|---|
| `sign`, `check` | info | the whole request, with the key name and payload hash; ends with a `done` event carrying `outcome`, `failed_check` and `sent` |
| `key_lock` | info | waiting for the key's signing lock |
| `audit_read` | info | reading the key's recent audit entries, with how many were read |
| `approval` | info | waiting for a person at the terminal (`confirm` keys) |
| `confirm` | info | sending and polling until the outcome is known |
| `audit_append` | info | writing one audit entry, with its command |
| `rpc` | debug | each RPC call, with its method name |
| `request` | info | each `serve` request, with the method, path, status and token name |

While tracing is on, `serve` no longer prints its plain access line (`POST /v1/sign -> 200 token=agent`), so a JSON log holds only JSON: the `request` span carries the same method, path, status and token name, plus the duration.

Spans never carry key material, partial signers, tokens or their hashes, the RPC URL (it may hold an API key), or caller-supplied text (`summary`, `intent`).
