Your policy engine pays an external address only when the policy document in force lists it in that chain’s whitelist. The document is yours. Your policy key (key B) signs it, and your engine verifies the signature and installs the document. Citadel never signs a policy document. From your backend you keep the whitelist, your address book, in step with your own records. You draft the document, check it, sign it and publish it. People with passkeys approve each change before it reaches your engine.

The round

1

Draft

POST /policy/drafts with the whole document. The answer carries document: the exact bytes to sign.
2

Check

Parse document and make sure it says what you sent. Sign only bytes you have read.
3

Sign

Sign the bytes of document with your policy key (key B), in your engine’s OpenBao.
4

Publish

POST /policy/publish with version and the signature. The change is pending: it waits for approval.
5

Approval

People approve it in the console. Citadel then sends it to your engine. GET /policy/drafts/{version} reads published, and GET /policy reads the document in force.
Writing needs a key with the policy.write scope. Reading needs read. A key limited to vaults can do neither: one document rules every vault. No call here takes network: one document rules every network.

Draft the whole document

The body is the whole document: the rules of each chain you use, the ceilings and the per-reference limits. A section you leave out is empty, not kept from the version in force. An empty whitelist lets your engine pay no external address, and an asset without a ceiling cannot be sent. GET /policy returns the document in force, so a change can start from it.
Citadel adds two things to your rules: the next version, and each chain’s chain_module_version, the address format the chain’s rules are written in. Both are inside the signed bytes. Your engine keeps the highest version it has seen, so an old document can never come back. Citadel refuses the draft with 422 before it replaces anything when it is not a document your engine can read. That covers a field the document does not have, an address its chain cannot parse, an amount that is not an integer string, and a window that is not a duration such as 24h. There is one draft per organization. A new draft replaces the one waiting to be signed, whether it was made here or in the console.

Check before you sign

Your engine trusts your policy key’s signature and nothing else. Read the bytes before you sign them. The document is your body plus version and each chain’s chain_module_version. Nothing else is in it:

Sign with key B

Key B is the policy key in your engine’s OpenBao, and it never leaves it. Sign the bytes of document exactly as answered: UTF-8, the trailing newline included. Write them to a file rather than a shell variable, which drops the trailing newline:
  • Through your policy engine’s image. citadel policy-engine sign-policy reads the document on stdin and prints the signature in hex, on the line after signature. Run it inside the engine’s container, which already holds the OpenBao login:
  • OpenBao directly. Ed25519 signs the whole message, so digest carries the document’s bytes themselves, hex-encoded, and not a hash of them. The answer’s data.signature is the signature in hex:
A backend that can sign with key B can propose any rules. Give it a token that may update multichainsign/keys/policy/sign and nothing else. Keep that token apart from the one that signs payout requests with key A (keys/request/sign). The approval is the people’s check on what it proposes.

Publish and wait for approval

The signature goes in hex, as sign-policy prints it, or in standard base64. Citadel checks it against the policy key your engine registered when it joined, and answers 202 with status: pending. The change does not reach your engine yet. An approval request opens in the console with the bar of your approval rules in force. It needs as many approvals as their strictest rule asks, from that rule’s group. When no approval rules are in force, or they hold nothing, one member of your organization approves. Every approver confirms with a passkey. Approvers see every whitelist address added and removed, in full, and the other parts of the document the change touches. Follow the change with GET /policy/drafts/{version}: A rejected version number is never used again. The next draft takes the one after it.

Keep it in step

A backend that syncs its address book on a schedule can run the same round every time:
  • 201: the rules changed. Check, sign, publish.
  • 200 with status: pending: these rules already wait for approval. Nothing to sign; the draft is left as it was.
  • 422 with code: nothing_changed: these are the rules in force. Nothing to do.
A newer change replaces one still waiting, and the approvals it had are lost. A version published in the console in the meantime does the same. Publish again only when your records changed. Sending the same version and signature again answers the change as it stands. A retry after a lost answer is safe.

When it is refused

Nothing changes on a refusal. See Errors for the codes every endpoint shares.