signafAIde
Docs menu

Production guide

The SDK keeps a small local state for each project: the agents' private keys, each agent's chain of records, and the outbox of records waiting for the witness. Everything below follows from two rules: an agent name is one chain, written by one running instance, and that instance needs its key and the end of its chain.

Where the state lives #

By default the state is .signafaide/ in the project folder. Set SIGNAFAIDE_HOME to put it anywhere else, for example a mounted volume:

Terminal
SIGNAFAIDE_HOME=/var/lib/signafaide

signafaide init and signafaide agent setup add .signafaide/ (and .env, which holds the API key after init) to .gitignore. The SDK never edits .gitignore while your program runs, so a CI checkout stays clean.

If the folder cannot be written, a wrapped tool does not run: the call raises StateError (strict mode or not), with a message that names the folder and what to do, never a raw EACCES. signafAIde fails closed here, because a tool that ran with no record would be an action nobody can show later:

StateError: cannot write signafAIde's local state in /app/.signafaide (read-only file system). Set SIGNAFAIDE_HOME to a writable folder ...

SIGNAFAIDE_DISABLED=1 is the deliberate way to run tools without recording.

Agent names and replicas #

Give every agent an explicit name. Without one, the SDK uses the project folder's name, which in a container is often app:

Python

tools = signafaide.wrap_tools(tools, agent="claims-bot")

TypeScript

const tools = wrapTools(tools, { agent: "claims-bot" });

One agent name is one chain, and a chain has one writer. When you run several replicas of the same agent, give each its own name, for example with the instance or pod name: claims-bot-0, claims-bot-1. Two processes on one machine sharing one SIGNAFAIDE_HOME may use the same name: they take turns through a lock file. Two machines with the same name and separate state would fork the chain, and the witness refuses the second (chain_conflict).

Keys from a secret store #

Each agent's private key is a small JSON file, .signafaide/agents/<agent>.key. In production, keep it in your secret store and hand it to the SDK through the environment. Make a live key for the store with keys create: it generates the key, registers it with the witness under your live API key and prints the key file on one line, without writing a record or saving anything on the machine:

Terminal
export SIGNAFAIDE_API_KEY=sfa_live_...
signafaide keys create --agent claims-bot-0 --env live --print > claims-bot-0.key   # store it, then delete the file
# in the secret store: SIGNAFAIDE_AGENT_KEY_LIVE_CLAIMS_BOT_0 = the line in claims-bot-0.key

Run it once per agent name (one per replica). It is refused when the agent already has an active key on the witness: replace that one with signafaide keys rotate where it is, or revoke it in the console first. For a key file you already have:

Terminal
SIGNAFAIDE_AGENT_KEY_CLAIMS_BOT="$(cat .signafaide/agents/claims-bot.key)"

The variable name is the agent name in capitals, with every character other than a letter or digit turned into _. A live chain looks for SIGNAFAIDE_AGENT_KEY_LIVE_<NAME> first, then the plain name. In code, pass a key provider instead:

Python

signafaide.init(key_provider=lambda agent, scope: secrets.get(f"signafaide/{agent}"))

TypeScript

await init({ keyProvider: (agent, scope) => secrets.get(`signafaide/${agent}`) });

Sandbox and live chains use separate keys: the live key is .signafaide/agents/live/<agent>.key. A key exposed while testing in the sandbox never signs live records. signafaide keys export lists both for your auditors. To rotate a key that lives in a secret store, run signafaide keys rotate where the key file is, store the new file, and keep the old one in .signafaide/agents/retired/ (or agents/live/retired/) so the export still lists it.

Containers #

Mount a volume and point SIGNAFAIDE_HOME at it. The keys can come from the volume or from the environment. With a volume, a restarted container continues its chains where they stopped.

Ephemeral and read-only disks #

Containers without a persistent disk, serverless functions and autoscaled replicas lose .signafaide/ when they stop. Check these five before you go live:

  1. Content. By default the inputs and outputs of each call stay only in .signafaide/chain/. On an ephemeral disk they go with the instance, and an auditor can then check that records are intact but cannot read what they say. Mount a volume for SIGNAFAIDE_HOME, or turn on stored content (store_content=True, storeContent: true; live records need Team), so the witness keeps the content encrypted.
  2. Heads. head.json holds the newest co-signed receipt of each chain, which lets an auditor prove nothing was cut from the end. Keep it outside the instance with SIGNAFAIDE_HEAD_FILE=/mnt/state/head.json (a volume or mounted bucket; several instances can share one file), or the on_head / onHead hook in code. Without it, that check rests on the witness's own heads.
  3. Strict mode for actions that cannot be undone. Tools that move money, freeze or close accounts, delete data or send messages: wrap them with strict=True (strict: true). The tool runs only once the witness has co-signed its decision record, so no such action can happen without a record that outlives the instance. If the witness cannot be reached, the tool does not run.
  4. One agent name per replica. Give each instance its own name (claims-bot-0, claims-bot-1), each with its own key from keys create.
  5. A clean stop. End each instance with signafaide.stop() (await stop()), for example in its SIGTERM handler: it sends what is left and writes a signed stop note. The next instance's start is then a clean restart. Without it every routine restart shows as an unclean start, a warning that earlier unsent records are not known.

Python

signafaide.init(on_head=lambda heads: bucket.put("signafaide/heads.json", json.dumps(heads)))
refunds = signafaide.wrap_tools([issue_refund, freeze_account], agent="risk-0", strict=True)

TypeScript

await init({ onHead: async (heads) => bucket.put("signafaide/heads.json", JSON.stringify(heads)) });
const refunds = wrapTools({ issueRefund, freezeAccount }, { agent: "risk-0", strict: true });

On a read-only disk, set SIGNAFAIDE_HOME to a writable folder: otherwise wrapped tools do not run (see above). With no persistent volume, set SIGNAFAIDE_HOME to a writable temporary folder (/tmp/signafaide) and give the keys through the environment or a key provider. A new instance does not know the end of its chain, so it writes signed drafts, even when init() already knows the project; when it first reaches the witness, it asks for the agent's newest receipt and continues the chain after it (a brand-new agent starts at 1). This needs one writer per agent name: run one instance per name.

The first record such an instance writes is a signed note, signafaide.instance_start, saying that it continues after seq N. What it means depends on the record before it:

  • Clean restart. The earlier instance ended with stop() (or ended by itself with SIGNAFAIDE_HEAD_FILE set): it sent everything, then wrote a signed note, signafaide.instance_stop, as its last record. A start right after that note needs no warning, and the verifier, the console and the reports count it as a clean restart.
  • Unclean start. There is no stop note right before the start: the earlier instance crashed, was killed, or could not reach the witness. Records it wrote but had not sent are not known, and nothing can show whether there were any. The verifier reports it under agent chains, and the console (Overview and Chain health), the auditor view and the verification report count it, so such a chain is never presented as complete without saying so.

Python

import signal, sys, signafaide

def on_term(signum, frame):
    signafaide.stop()   # sends what is left, then the signed stop note
    sys.exit(0)

signal.signal(signal.SIGTERM, on_term)

TypeScript

import { stop } from "signafaide";

process.on("SIGTERM", async () => {
  await stop();
  process.exit(0);
});

A stop note is written only when nothing is left unsent. If the witness cannot be reached at shutdown, no stop note reaches it, and the next start is unclean, as it should be.

signafaide verify on such an instance passes and says where its local chain starts: continues after a co-signed record this machine does not hold: <chain> after seq N. The earlier records are on the witness and in the verification pack.

Records not yet sent when an ephemeral instance stops are lost with its disk. Send them before it stops (see Serverless), and use strict mode for the actions that matter most (checklist above).

Serverless #

On AWS Lambda, Google Cloud Run and similar, use SIGNAFAIDE_HOME=/tmp/signafaide, keys from the environment, and send the outbox before the handler returns:

Python

signafaide.flush()

TypeScript

await flush();

When a program ends with records still waiting, the SDK makes one last attempt, holding the process at most 3 seconds (SIGNAFAIDE_EXIT_FLUSH_MS, 0 turns it off). Background requests never keep a finished program open, so a witness that stops answering cannot hold your job.

CI and tests #

  • Tests never send records. The SDK turns test mode on by itself under pytest, Vitest, Jest and node --test: records go to a temporary folder and nothing is sent. SIGNAFAIDE_TEST=1 forces it, SIGNAFAIDE_TEST=0 turns it off.
  • Off switch. SIGNAFAIDE_DISABLED=1 makes record() and wrapTools() call your functions and write nothing.
  • A CI gate. signafaide verify --fail-on-pending-send fails while any record waits to be co-signed, and a BLOCKED chain always fails (see Verify). signafaide status exits 1 when a chain is BLOCKED.

Sending and errors #

Records ship in the background, one request at a time, with a backoff that grows to a minute when the witness cannot be reached. A refusal that retrying cannot fix (quota used up, a wrong or revoked API key, a revoked agent key) stops sending until the API key, SIGNAFAIDE_URL or the agent keys change; signafaide flush tries again at once. A malformed SIGNAFAIDE_API_KEY is caught on your machine and never sent. Outside strict mode nothing in a tool call waits for the network or raises because of it; problems are one warning line each.

Clocks #

A record's time is never earlier than the record before it in its chain, or than its key's creation. If the machine's clock steps back (an NTP correction, a resumed VM), the record gets the previous record's time and the clock's own reading is kept in its content as meta.wall_time. A clock that is ahead of the witness's by more than 5 minutes makes the witness refuse records; fix the clock. signafaide repair handles chains that can never ship: it sets those records aside (kept, still verifiable), gives the agent a new key and starts a new segment. See Verify.

Settings #

variablemeaning
SIGNAFAIDE_API_KEYthe project's API key (also read from .env)
SIGNAFAIDE_URLthe witness; default https://api.signafaide.co.uk
SIGNAFAIDE_HOMEwhere the local state lives; default .signafaide/ in the project
SIGNAFAIDE_AGENT_KEY_<NAME>an agent's key file content, from your secret store (SIGNAFAIDE_AGENT_KEY_LIVE_<NAME> for a live key; signafaide keys create --print makes one)
SIGNAFAIDE_HEAD_FILEalso keep the newest signed heads in this file, outside the instance (a volume or mounted bucket)
SIGNAFAIDE_INSTANCE_STOP1: write the signed stop note when a program ends with nothing left to send, even without a head file; 0: never (with a head file set it is on)
SIGNAFAIDE_EXIT_FLUSH_MSthe longest a finished program is kept open to send (default 3000)
SIGNAFAIDE_TEST1 forces test mode, 0 turns it off under a test runner
SIGNAFAIDE_DISABLED1 turns recording off
SIGNAFAIDE_SEGMENT_BYTESa shipped chain file larger than this rolls into a sealed segment (default 64 MiB)
SIGNAFAIDE_TELEMETRY0 leaves the adapter, the coding agent and usage events out of the client header