diff --git a/.github/workflows/cut-versions.yml b/.github/workflows/cut-versions.yml
index f94fbc11..6cad2933 100644
--- a/.github/workflows/cut-versions.yml
+++ b/.github/workflows/cut-versions.yml
@@ -71,9 +71,21 @@ jobs:
- name: Install deps
run: npm ci
+ - name: Test snapshot workflow
+ run: node --test scripts/cut-versions.test.mjs
+
- name: Parse release manifest (and merge with manual inputs)
id: manifest
env:
+ INPUT_VERSION_LABEL: ${{ inputs.version_label }}
+ INPUT_MIDEN_NODE_REF: ${{ inputs.miden_node_ref }}
+ INPUT_MIDEN_VM_REF: ${{ inputs.miden_vm_ref }}
+ INPUT_MIDEN_BASE_REF: ${{ inputs.miden_base_ref }}
+ INPUT_MIDEN_CLIENT_REF: ${{ inputs.miden_client_ref }}
+ INPUT_COMPILER_REF: ${{ inputs.compiler_ref }}
+ INPUT_MIDEN_TUTORIALS_REF: ${{ inputs.miden_tutorials_ref }}
+ INPUT_GUARDIAN_REF: ${{ inputs.guardian_ref }}
+ INPUT_NOTE_TRANSPORT_REF: ${{ inputs.note_transport_ref }}
INPUT_BRIDGE_PORTAL_REF: ${{ inputs.bridge_portal_ref }}
run: |
node - <<'NODE'
@@ -113,22 +125,45 @@ jobs:
process.exit(1);
}
- // Emit outputs
- for (const [k,v] of Object.entries(out)) {
- console.log(`::set-output name=${k}::${v}`);
+ // A locally generated snapshot may already accompany the manifest push.
+ // Skip generation only when all three snapshot artifacts are present.
+ const versions = JSON.parse(read('versions.json') || '[]');
+ const existing = [
+ versions.includes(out.version_label),
+ fs.existsSync(`versioned_docs/version-${out.version_label}`),
+ fs.existsSync(`versioned_sidebars/version-${out.version_label}-sidebars.json`),
+ ];
+ if (existing.some(Boolean) && !existing.every(Boolean)) {
+ console.error(`Incomplete snapshot for ${out.version_label}; refusing to overwrite it.`);
+ process.exit(1);
}
+ out.snapshot_exists = String(existing.every(Boolean));
+
+ // Emit simple, single-line outputs through the supported Actions file.
+ const output = Object.entries(out).map(([key, value]) => `${key}=${value}`).join('\n');
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output}\n`);
NODE
- name: Validate refs exist
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
set -e
validate() {
repo="$1"; ref="$2"
echo "Validating $repo @ $ref"
- git ls-remote "https://github.com/$repo" "$ref" | grep -q . || {
- echo "ERROR: Ref '$ref' not found in $repo"
- exit 1
- }
+ if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
+ resolved=$(gh api "repos/$repo/git/commits/$ref" --jq '.sha') || exit 1
+ [[ "$resolved" == "$ref" ]] || {
+ echo "ERROR: Commit '$ref' did not resolve exactly in $repo"
+ exit 1
+ }
+ else
+ git ls-remote "https://github.com/$repo" "$ref" | grep -q . || {
+ echo "ERROR: Ref '$ref' not found in $repo"
+ exit 1
+ }
+ fi
}
validate "${REPO_NODE}" "${{ steps.manifest.outputs.miden_node_ref }}"
validate "${REPO_VM}" "${{ steps.manifest.outputs.miden_vm_ref }}"
@@ -141,6 +176,7 @@ jobs:
validate "${REPO_BRIDGE_PORTAL}" "${{ steps.manifest.outputs.bridge_portal_ref }}"
- name: Checkout sources at validated refs
+ if: steps.manifest.outputs.snapshot_exists != 'true'
run: |
set -e
checkout_ref() {
@@ -167,6 +203,7 @@ jobs:
# This content is cleaned up after versioning (see "Clean up" step below)
# ============================================================
- name: Aggregate docs into v0.4 IA structure
+ if: steps.manifest.outputs.snapshot_exists != 'true'
run: |
echo "Aggregating vendor docs into v0.4 IA structure..."
@@ -333,6 +370,7 @@ jobs:
ls -la docs/builder/tutorials/ || true
- name: Cut version snapshot
+ if: steps.manifest.outputs.snapshot_exists != 'true'
run: |
VERSION="${{ steps.manifest.outputs.version_label }}"
echo "Creating Docusaurus version: $VERSION"
@@ -346,6 +384,7 @@ jobs:
# docs/ must only contain authored content (builder/, reference/ landing pages)
# All ingested content lives in versioned_docs/ after versioning
- name: Clean up (delete vendor and ingested docs)
+ if: steps.manifest.outputs.snapshot_exists != 'true'
run: |
rm -rf vendor
# Guardian: remove ingested subdirs (keep authored index.md); note-transport (fully ingested)
@@ -379,6 +418,7 @@ jobs:
rm -rf docs/builder/tutorials/recipes/img
- name: Commit snapshots
+ if: steps.manifest.outputs.snapshot_exists != 'true'
run: |
if ! git diff --quiet; then
git config user.name "github-actions[bot]"
diff --git a/.release/release-manifest.yml b/.release/release-manifest.yml
index 17cad613..bbea29b4 100644
--- a/.release/release-manifest.yml
+++ b/.release/release-manifest.yml
@@ -1,11 +1,12 @@
-version: "0.15" # the label to snapshot (used if no manual override)
-refs: # exact, immutable refs per source repo (tags or release branches)
- protocol: "refs/tags/v0.15.3"
- node: "refs/tags/v0.15.0"
- miden-client: "refs/tags/v0.15.2"
- tutorials: "refs/heads/main"
- miden-vm: "refs/tags/v0.23.4"
- compiler: "refs/tags/v0.8.1"
- guardian: "refs/tags/v0.15.0"
- note-transport: "refs/tags/v0.4.1"
-next_version: "0.16"
+version: "0.16"
+refs: # Immutable documentation source commits, resolved on 2026-09-14.
+ protocol: "fb1e40da0f7cec156963e5d815a51a18a8313f25" # v0.16.1
+ node: "d6ce8b14d4680e0187b877c1de5c1cdeea16e7e2" # v0.16.0
+ miden-client: "5f36964d2655549e600f55ab6f65caea943d1130" # rust-sdk v0.16.1
+ tutorials: "965739b626acf3c63a0e547d313859079ee03c18" # v0.16 migration + NFT recipe (#249, #250)
+ miden-vm: "c1c2ddcb9cabc3d5c2071f8baf68c7930917cad8" # v0.29.2, the protocol v0.16 VM line
+ compiler: "f2e0b4274f4f13caeceb215645bd3f1f03a3c6f5" # v0.10.1; still pins protocol v0.16.0-rc.4
+ guardian: "be66822395551c630a164f7bdafbb67771c83442" # v0.17.0, built for Miden v0.16
+ note-transport: "9736b39d64cfb4d77bb2d777a9ad1a4f2ea3f2a5" # v0.5.0-rc.2, used by rust-sdk v0.16
+ bridge-portal: "5d8f327a8adea0e0d671b798145b6092f5e23d6a" # Developer docs #117; portal still uses SDK v0.15
+next_version: "0.17"
diff --git a/docs/builder/get-started/setup/installation.md b/docs/builder/get-started/setup/installation.md
index 23be8a67..e153951a 100644
--- a/docs/builder/get-started/setup/installation.md
+++ b/docs/builder/get-started/setup/installation.md
@@ -128,13 +128,13 @@ toolchain for the network you use. `midenup` resolves network names through the
[published channel manifest](https://0xmiden.github.io/midenup/channel-manifest.json).
:::warning Release prerequisite
-The published manifest currently maps `testnet` to the v0.15 channel. The
-explicit `0.16.0` channel is not a substitute: it still mixes prerelease client
-and protocol components. Neither channel is a verified setup for these v0.16
-testnet guides, and `devnet` is a different network.
+The stable v0.16 toolchain update is ready on midenup's `next` branch, but it
+has not yet been promoted to the published manifest. The published `testnet`
+channel still points to v0.15, while its explicit `0.16.0` channel is the older
+prerelease stack.
-Do not continue with the network-dependent guides until `testnet` points to a
-coherent v0.16 release channel.
+Wait for the updated manifest to be published before using these
+network-dependent v0.16 guides against testnet.
:::
After the manifest meets that requirement, install the public testnet toolchain
diff --git a/scripts/cut-versions.test.mjs b/scripts/cut-versions.test.mjs
new file mode 100644
index 00000000..74057a75
--- /dev/null
+++ b/scripts/cut-versions.test.mjs
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { createRequire } from "node:module";
+import vm from "node:vm";
+import test from "node:test";
+import YAML from "yaml";
+
+const require = createRequire(import.meta.url);
+const workflow = YAML.parse(readFileSync(new URL("../.github/workflows/cut-versions.yml", import.meta.url), "utf8"));
+const steps = workflow.jobs.cut.steps;
+const parser = steps.find((step) => step.id === "manifest");
+const parseCode = parser.run.match(/node - <<'NODE'\n([\s\S]*?)\nNODE/)[1];
+
+function parse({ inputs = {}, present = [] } = {}) {
+ const outputs = {};
+ const env = Object.fromEntries(Object.entries(parser.env || {}).map(([key, value]) => {
+ const input = value.match(/inputs\.([a-z_]+)/)?.[1];
+ return [key, inputs[input] || ""];
+ }));
+ env.GITHUB_OUTPUT = "__outputs__";
+ const files = {
+ ".release/release-manifest.yml": "version: '0.16'\nrefs:\n tutorials: pinned-tutorial-commit\n",
+ "versions.json": JSON.stringify(present.includes("version-list") ? ["0.16", "0.15"] : ["0.15"]),
+ };
+ const paths = new Set(Object.keys(files));
+ if (present.includes("directory")) paths.add("versioned_docs/version-0.16");
+ if (present.includes("sidebar")) paths.add("versioned_sidebars/version-0.16-sidebars.json");
+ vm.runInNewContext(parseCode, {
+ require: (name) => name === "fs" ? {
+ existsSync: (path) => paths.has(path),
+ readFileSync: (path) => { assert.ok(path in files, `unexpected read: ${path}`); return files[path]; },
+ appendFileSync: (path, contents) => {
+ assert.equal(path, env.GITHUB_OUTPUT);
+ for (const line of contents.trimEnd().split("\n")) {
+ const separator = line.indexOf("=");
+ assert.ok(separator > 0, `invalid output line: ${line}`);
+ outputs[line.slice(0, separator)] = line.slice(separator + 1);
+ }
+ },
+ } : require(name),
+ process: { env, exit: (code) => { throw new Error(`exit ${code}`); } },
+ console: { error() {}, log() {} },
+ });
+ return outputs;
+}
+
+test("manifest outputs use GITHUB_OUTPUT instead of the deprecated command", () => {
+ assert.match(parseCode, /GITHUB_OUTPUT/);
+ assert.doesNotMatch(parseCode, /::set-output/);
+});
+
+test("manual version and source overrides reach the parser instead of silently using the manifest", () => {
+ const outputs = parse({ inputs: {
+ version_label: "0.99", miden_node_ref: "node-pin", miden_vm_ref: "vm-pin",
+ miden_base_ref: "protocol-pin", miden_client_ref: "client-pin", compiler_ref: "compiler-pin",
+ miden_tutorials_ref: "tutorial-pin", guardian_ref: "guardian-pin",
+ note_transport_ref: "transport-pin", bridge_portal_ref: "bridge-pin",
+ } });
+ assert.equal(outputs.version_label, "0.99");
+ for (const [key, expected] of Object.entries({ miden_node_ref: "node-pin", miden_vm_ref: "vm-pin",
+ miden_base_ref: "protocol-pin", miden_client_ref: "client-pin", compiler_ref: "compiler-pin",
+ miden_tutorials_ref: "tutorial-pin", guardian_ref: "guardian-pin", note_transport_ref: "transport-pin",
+ bridge_portal_ref: "bridge-pin" })) assert.equal(outputs[key], expected);
+});
+
+test("absent snapshot is eligible for generation and uses manifest defaults", () => {
+ const outputs = parse();
+ assert.equal(outputs.version_label, "0.16");
+ assert.equal(outputs.miden_tutorials_ref, "pinned-tutorial-commit");
+ assert.equal(outputs.snapshot_exists, "false");
+});
+
+test("complete checked-in snapshot validates refs but skips generation and cleanup", () => {
+ const outputs = parse({ present: ["version-list", "directory", "sidebar"] });
+ assert.equal(outputs.snapshot_exists, "true");
+ const validationStep = steps.find((step) => step.name === "Validate refs exist");
+ assert.equal(validationStep.if, undefined, "source refs must still be validated");
+ for (const step of steps.slice(steps.indexOf(validationStep) + 1)) {
+ const condition = (step.if || "true").replace(/\$\{\{|\}\}/g, "")
+ .replaceAll("steps.manifest.outputs.snapshot_exists", JSON.stringify(outputs.snapshot_exists));
+ assert.equal(vm.runInNewContext(condition), false, `${step.name} would run on an existing snapshot`);
+ }
+});
+
+for (const present of [["directory"], ["version-list"], ["sidebar"], ["version-list", "directory"]]) {
+ test(`partial snapshot is rejected: ${present.join("+")}`, () => assert.throws(() => parse({ present })));
+}
+
+const validation = steps.find((step) => step.name === "Validate refs exist").run.split('\nvalidate "${REPO_NODE}"')[0];
+const sha = "965739b626acf3c63a0e547d313859079ee03c18";
+function validate(ref, apiSha = sha) {
+ const boundary = `
+git() { if [[ "$3" == "refs/tags/v0.16.0" ]]; then echo "${sha} refs/tags/v0.16.0"; fi; }
+gh() { [[ "$1" == "api" && "$2" == "repos/0xMiden/tutorials/git/commits/${sha}" ]] || return 1; [[ -n "$API_SHA" ]] || return 1; echo "$API_SHA"; }
+`;
+ return spawnSync("bash", ["-c", `${boundary}\n${validation}\nvalidate 0xMiden/tutorials "$TEST_REF"`], {
+ env: { ...process.env, TEST_REF: ref, API_SHA: apiSha }, encoding: "utf8",
+ });
+}
+test("an existing exact commit pin is accepted", () => assert.equal(validate(sha).status, 0));
+test("a missing exact commit is rejected", () => assert.notEqual(validate(sha, "").status, 0));
+test("a mismatched commit response is rejected", () => assert.notEqual(validate(sha, "0".repeat(40)).status, 0));
+test("normal tag validation still works", () => assert.equal(validate("refs/tags/v0.16.0").status, 0));
+test("an unknown tag is rejected", () => assert.notEqual(validate("refs/tags/missing").status, 0));
diff --git a/sidebars.ts b/sidebars.ts
index 86fb741f..0ed69b85 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -149,6 +149,7 @@ const sidebars: SidebarsConfig = {
"builder/tutorials/recipes/rust/counter_contract_tutorial",
"builder/tutorials/recipes/rust/create_deploy_tutorial",
"builder/tutorials/recipes/rust/mint_consume_create_tutorial",
+ "builder/tutorials/recipes/rust/nft_mint_transfer",
"builder/tutorials/recipes/rust/creating_notes_in_masm_tutorial",
"builder/tutorials/recipes/rust/custom_note_how_to",
"builder/tutorials/recipes/rust/unauthenticated_note_how_to",
diff --git a/versioned_docs/version-0.16/builder/_category_.json b/versioned_docs/version-0.16/builder/_category_.json
new file mode 100644
index 00000000..34d2480b
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/_category_.json
@@ -0,0 +1,6 @@
+{
+ "label": "Builder",
+ "position": 1,
+ "collapsible": false,
+ "collapsed": false
+}
diff --git a/versioned_docs/version-0.16/builder/faq.md b/versioned_docs/version-0.16/builder/faq.md
new file mode 100644
index 00000000..e007a20c
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/faq.md
@@ -0,0 +1,95 @@
+# FAQ
+
+## How is privacy implemented in Miden?
+
+Miden leverages zero-knowledge proofs and client side execution and proving to provide security and privacy.
+
+## Does Miden support encrypted notes?
+
+At the moment, Miden does not have support for encrypted notes but it is a planned feature.
+
+## Why does Miden have delegated proving?
+
+Miden leverages delegated proving for a few technical and practical reasons:
+
+1. **Computational:** Generating zero-knowledge proofs is a computationally intensive work. The proving process requires significant processing power and memory, making it impractical for some end-user devices (like smartphones) to generate.
+2. **Technical architecture**:
+Miden's architecture separates concerns between:
+ - **Transaction Creation**: End users create and sign transactions
+ - **Proof Generation**: Specialized provers generate validity proofs
+ - **Verification**: The network verifies these proofs
+3. **Proving efficiency**:
+Delegated provers can use optimized hardware that wouldn't be available to end-user devices, specifically designed for the mathematical operations needed in STARK proof generation.
+
+## What is the lifecycle of a transaction?
+
+For a client-executed transaction:
+
+### 1. Transaction Creation
+
+- User creates a transaction specifying the operations to perform (transfers, contract interactions, etc.)
+- Client loads the account state and input notes needed for execution
+
+### 2. Transaction Execution
+
+- The Miden VM executes the note and transaction scripts locally
+- The resulting state transitions and execution trace are computed
+- During authentication, the account pays the protocol fee through a public `TX_FEE` note and authorizes the transaction summary
+
+### 3. Proof Generation
+
+- The client or a delegated prover generates a cryptographic proof attesting to the correctness of the execution
+
+### 4. Transaction Submission
+
+- The proven transaction, sealed inputs, and public state updates are submitted to Miden network nodes
+- The node verifies the proof and admission rules before accepting the transaction into the mempool
+
+### 5. Transaction Selection
+
+- Accepted transactions are selected from the mempool
+- Transactions are grouped into batches for inclusion in a block
+
+### 6. Block Production
+
+- Transaction batches and their proofs are assembled into a block
+- The client synchronizes to observe the transaction's inclusion and updated state
+
+### 7. L1 Submission
+
+- Block proofs are aggregated for settlement
+- The resulting proof and state commitments are submitted for L1 verification
+
+### 8. Finalization
+
+- L1 settlement establishes finality for the submitted state
+- Inclusion in a Miden block does not by itself establish L1 finality
+
+## Do notes in Miden support recency conditions?
+
+Yes, Miden enables consumption of notes based on time conditions, such as:
+
+- A specific block height being reached
+- A timestamp threshold being passed
+- An oracle providing specific data
+- Another transaction being confirmed
+
+## What does a Miden operator do in Miden?
+
+A Miden operator is an entity that maintains the infrastructure necessary for the functioning of the Miden layer 2. Their roles may involve:
+
+1. Running Sequencer Nodes
+2. Operating the Prover Infrastructure
+3. Submitting Proofs to L1
+4. Maintaining Data Availability
+5. Participating in the Consensus Mechanism
+
+## How does bridging work in Miden?
+
+Miden testnet applications can integrate [Agglayer or Epoch](./tools/bridging/).
+Agglayer provides canonical bridge semantics for ETH, while Epoch provides a
+faster quote-and-solve flow for test USDC. Both integrations are testnet-only.
+
+## What does the gas fee model of Miden look like?
+
+Miden does not meter gas linearly. Its transaction fee grows logarithmically with the transaction's estimated VM cycles. During authentication, the account pays by creating a public `TX_FEE` note that the batch builder can collect. See [Transaction Fees](./smart-contracts/transactions/fees) for the formula, payment assets, and network-account sponsorship fees.
diff --git a/versioned_docs/version-0.16/builder/get-started/_category_.json b/versioned_docs/version-0.16/builder/get-started/_category_.json
new file mode 100644
index 00000000..f230021f
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Get Started",
+ "position": 1,
+ "link": {
+ "type": "doc",
+ "id": "builder/get-started/index"
+ }
+}
diff --git a/versioned_docs/version-0.16/builder/get-started/accounts.md b/versioned_docs/version-0.16/builder/get-started/accounts.md
new file mode 100644
index 00000000..bce7e4ac
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/accounts.md
@@ -0,0 +1,400 @@
+---
+sidebar_position: 2
+title: Accounts
+description: Learn how to create and manage Miden accounts programmatically using Rust and TypeScript.
+---
+
+# Accounts
+
+Miden's account model is fundamentally different from traditional blockchains. Let's explore how to create and manage accounts programmatically.
+
+## Understanding Miden Accounts
+
+Before diving into account creation, it's essential to understand what makes Miden accounts unique compared to traditional blockchain addresses.
+
+**What Makes Miden Accounts Special:**
+
+- **Smart Contract Wallets**: Every account is a programmable smart contract that can hold assets and execute custom logic
+- **Modular Design**: Accounts are composed of reusable components (authentication, wallet functionality, etc.)
+- **Privacy Levels**: Choose whether the full account state is public or privately held
+
+Miden accounts differ from traditional blockchain addresses in fundamental ways.
+
+**Account Architecture:**
+
+- Every account is a **smart contract** with programmable logic
+- Accounts can store **assets** (fungible and non-fungible tokens) in their vault
+- Each account has **storage slots** for custom data
+- Accounts are composed of **modular components** for different functionalities
+
+**Account-state visibility:**
+
+- **Public**: All state visible onchain (transparent operations)
+- **Private**: Only commitments onchain, full state held privately
+
+### Account Structure
+
+Every Miden account contains these core components:
+
+- **Vault**: Secure asset storage
+- **Storage**: Key-value data store (up to 255 slots)
+- **Code**: Smart contract logic
+- **Nonce**: Anti-replay counter
+- **Components**: Modular functionality (authentication, wallet, etc.)
+
+
+Account Struct
+
+```rust
+pub struct MidenAccount {
+ /// Immutable, 120-bit ID encoding account visibility and version.
+ pub id: [u8; 15],
+
+ /// Determines whether account state is public or private.
+ pub account_type: AccountType,
+
+ /// Root commitment of the account CODE (MAST root).
+ pub code_commitment: [u8; 32],
+
+ /// Root commitment of the account STORAGE (slots / maps).
+ /// Think of this as the "root hash" of the Account's storage merkle tree.
+ pub storage_commitment: [u8; 32],
+
+ /// Vault commitment. For compact headers we keep only an optional aggregate commitment.
+ /// Indexers can materialize a richer view (e.g., list of assets) offchain.
+ pub vault_commitment: Option<[u8; 32]>,
+
+ /// Monotonically increasing counter; must increment exactly once when state changes.
+ pub nonce: u64,
+
+ /// Merged set of account components that defined this account's interface and storage.
+ /// Accounts are composed by merging components (e.g. wallet component + an auth component).
+ pub components: Vec,
+
+ /// Authentication procedure metadata (e.g. "Falcon512Poseidon2").
+ pub authentication: AuthenticationDescriptor,
+}
+```
+
+**Account Types:**
+
+```rust
+pub enum AccountType {
+ /// Account state is held privately by the owner.
+ Private,
+ /// Account state is visible onchain.
+ Public,
+}
+```
+
+Wallet, faucet, and custom-contract roles come from the account's components and creation options, not from the account ID.
+
+
+
+## Set Up Development Environment
+
+To run the code examples in this guide, you'll need to set up a development environment for either Rust or TypeScript.
+
+### Rust Environment
+
+If you already created `my-test-project` during [installation](./setup/installation#rust-project), you can reuse it. Otherwise, create a new project:
+
+```bash title=">_ Terminal"
+miden new my-project
+cd my-project/integration/
+```
+
+For each code example, create a new binary file:
+
+```bash title=">_ Terminal"
+touch src/bin/demo.rs
+```
+
+Copy the Rust code example into the file, then run:
+
+```bash title=">_ Terminal"
+cargo run --bin demo --release
+```
+
+### TypeScript Environment
+
+If you already created `miden-app` during [installation](./setup/installation#typescript-project), you can reuse it. Otherwise, scaffold a new Vite vanilla-ts project:
+
+```bash title=">_ Terminal"
+npm create vite@latest miden-app -- --template vanilla-ts
+cd miden-app
+npm install @miden-sdk/miden-sdk@^0.16.0
+```
+
+For each code example, save the TypeScript snippet as `src/demo.ts` (overwriting the previous one as you progress):
+
+```bash title=">_ Terminal"
+touch src/demo.ts
+```
+
+Wire it into the entry point once (`src/main.ts`):
+
+```ts title="src/main.ts"
+import { demo } from "./demo";
+
+demo().catch(console.error);
+```
+
+Run the dev server:
+
+```bash title=">_ Terminal"
+npm run dev
+```
+
+Open the dev-server URL in the browser and watch the devtools console for output.
+
+:::tip
+For detailed frontend setup guidance (React, wallets, UI), see the [Tutorials section](../tutorials/).
+:::
+
+## Creating Accounts Programmatically
+
+Let's start by creating accounts using the Miden client libraries:
+
+```rust title="integration/src/bin/account.rs"
+use miden_client::{
+ account::{
+ component::{AuthScheme, AuthSingleSig, BasicWallet},
+ AccountBuilder, AccountType,
+ },
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ rpc::Endpoint,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ let mut init_seed = [0_u8; 32];
+ client.rng().fill_bytes(&mut init_seed);
+
+ let key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ let builder = AccountBuilder::new(init_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet);
+
+ let account = builder.build()?;
+
+ client.add_account(&account, false).await?;
+
+ keystore.add_key(&key_pair, account.id()).await?;
+
+ println!("Account ID: {}", account.id());
+ println!("No Assets in Vault: {:?}", account.vault().is_empty());
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ const client = await MidenClient.createTestnet();
+
+ // Create a new wallet account.
+ const wallet = await client.accounts.create({
+ storage: "public", // Public: account state is visible onchain
+ });
+
+ console.log("Account ID:", wallet.id().toString());
+ console.log(
+ "No Assets in Vault:",
+ wallet.vault().fungibleAssets().length === 0,
+ );
+}
+```
+
+
+Expected output
+
+```text
+Account ID: 0x94733054a40d1610178320cc0c8060
+No Assets in Vault: true
+```
+
+
+
+## Creating a Token Faucet
+
+Before we can work with tokens, we need a source of tokens. Let's create a fungible token faucet:
+
+```rust title="integration/src/bin/faucet.rs"
+use miden_client::{
+ account::{
+ component::{
+ AuthScheme, AuthSingleSig, BurnPolicy, FungibleFaucet, MintPolicy, TokenName,
+ TokenPolicyManager, TransferPolicy, create_singlesig_user_fungible_faucet,
+ },
+ AccountType,
+ },
+ asset::{AssetAmount, TokenSymbol},
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ rpc::Endpoint,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ // Faucet seed
+ let mut init_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut init_seed);
+
+ // Faucet parameters
+ let symbol = TokenSymbol::new("TEST")?;
+ let decimals = 8;
+ let max_supply = AssetAmount::from(1_000_000u32);
+
+ // Generate key pair
+ let key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ // Build the faucet account
+ let faucet = FungibleFaucet::builder()
+ .name(TokenName::new("Test Token")?)
+ .symbol(symbol)
+ .decimals(decimals)
+ .max_supply(max_supply)
+ .build()?;
+ let policies = TokenPolicyManager::builder()
+ .active_mint_policy(MintPolicy::allow_all())
+ .active_burn_policy(BurnPolicy::allow_all())
+ .active_send_policy(TransferPolicy::allow_all())
+ .active_receive_policy(TransferPolicy::allow_all())
+ .build();
+ let auth = AuthSingleSig::new(Approver::new(
+ key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ ));
+ let faucet_account = create_singlesig_user_fungible_faucet(
+ init_seed,
+ faucet,
+ auth,
+ policies,
+ AccountType::Public,
+ )?;
+
+ client.add_account(&faucet_account, false).await?;
+ keystore.add_key(&key_pair, faucet_account.id()).await?;
+
+ println!("Faucet account ID: {}", faucet_account.id());
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // Faucet parameters
+ const decimals = 8;
+ const maxSupply = 10_000_000n * 10n ** BigInt(decimals);
+
+ // Create a fungible token faucet.
+ const faucet = await client.accounts.create({
+ type: 0, // Fungible faucet
+ symbol: "TEST",
+ decimals,
+ maxSupply,
+ storage: "public",
+ });
+
+ console.log("Faucet account ID:", faucet.id().toString());
+}
+```
+
+
+Expected output
+
+```text
+Faucet account ID: 0xde0ba31282f7522046d3d4af40722b
+```
+
+
+
+## Key Takeaways
+
+**Account Types:**
+
+- **Public Accounts**: Account state is fully transparent and visible onchain
+- **Private Accounts**: Only cryptographic commitments are stored onchain, with full state maintained privately
+
+**Modular Components:**
+
+- **BasicWallet**: Provides asset management functionality
+- **FungibleFaucet**: Provides token metadata and minting behavior
+- **TokenPolicyManager**: Configures mint, burn, send, and receive policies
+- **AuthSingleSig**: Handles cryptographic authentication (Falcon512 or ECDSA via the `AuthScheme` enum)
+
+Now that you understand how to create accounts and faucets, you're ready to learn about Miden's unique transaction model. Continue to [Notes & Transactions](./notes) to explore how assets move between accounts using notes.
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/index.md b/versioned_docs/version-0.16/builder/get-started/index.md
new file mode 100644
index 00000000..51c7550f
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/index.md
@@ -0,0 +1,46 @@
+---
+sidebar_position: 0
+title: Quick Start
+description: Get started with Miden by installing Miden tools using the `midenup` toolchain, creating your first wallet, performing basic operations, and building your first smart contract!
+pagination_prev: null
+---
+
+# Quick Start
+
+Welcome to Miden! This guide gets you up and running with the Miden blockchain by walking through the essential setup and core operations.
+
+## What is Miden?
+
+Miden is a privacy-focused, ZK-based blockchain that uses an actor model where each account is a smart contract. Unlike traditional blockchains where accounts simply hold balances, Miden accounts are programmable entities that can execute custom logic, store data, and manage assets autonomously.
+
+Key concepts you'll encounter:
+
+- **Accounts**: smart contracts that hold assets and execute code
+- **Notes**: messages that exchange data and assets between accounts — also programmable
+- **Assets**: tokens that can be fungible or non-fungible
+- **Privacy**: accounts and notes can be public or private. Private accounts keep their state offchain, while private notes hide their details behind commitments
+
+## Getting started
+
+Follow these guides in order:
+
+
+
+ Install the Miden toolchain with `midenup`.
+
+
+ Essential Miden CLI commands — create a wallet and mint your first tokens.
+
+
+ Create and manage Miden accounts programmatically in Rust and TypeScript.
+
+
+ Miden's note-based transaction model for private asset transfers.
+
+
+ Query account storage data and interact with deployed smart contracts.
+
+
+ Build, test, and deploy a smart contract on Miden using Rust.
+
+
diff --git a/versioned_docs/version-0.16/builder/get-started/notes.md b/versioned_docs/version-0.16/builder/get-started/notes.md
new file mode 100644
index 00000000..b0308ae1
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/notes.md
@@ -0,0 +1,1137 @@
+---
+sidebar_position: 3
+title: Notes & Transactions
+description: Learn Miden's unique note-based transaction model for asset transfers between accounts.
+---
+
+# Notes & Transactions
+
+Miden's transaction model is uniquely powerful, combining private asset transfers through notes with zero-knowledge proofs. Let's explore how to mint, consume, and send tokens using this innovative approach.
+
+## Understanding Miden's Transaction Model
+
+Traditional blockchains move tokens directly between account balances. Miden uses a more sophisticated **note-based system** that provides enhanced privacy and flexibility.
+
+**Think of Notes Like Sealed Envelopes:**
+
+- Alice puts 100 tokens in a sealed envelope (note) addressed to Bob
+- She posts the envelope to the public board (network)
+- Only Bob can open envelopes addressed to him
+- When Bob opens it, the 100 tokens move to his vault
+
+**Key Components:**
+
+- **Notes**: Sealed containers that carry data and assets between accounts
+- **P2ID (Pay-To-ID) Notes**: Notes addressed to a specific account ID (like Bob's address)
+- **Nullifiers**: Prevent someone from opening the same envelope twice
+- **Zero-Knowledge Proofs**: Prove transactions are valid without revealing private details
+
+## The Two-Transaction Model
+
+A core principle of Miden is that **a transaction is the state transition of a single account**. This means each transaction only modifies one account's state, which enables parallel execution and strong privacy guarantees.
+
+Miden uses a **two-transaction model** for asset transfers that provides enhanced privacy and scalability:
+
+### Transaction 1: Sender Creates Note
+
+- **Alice's account** creates a P2ID (Pay-To-ID) note containing 100 tokens
+- The note specifies **Bob** as the only valid consumer
+- Alice's balance decreases, note is available for consumption
+- Alice's transaction is complete and final
+
+### Transaction 2: Recipient Consumes Note
+
+- **Bob's client** discovers the note (addressed to his ID)
+- Bob creates a transaction to consume the note
+- Tokens move from the note into Bob's vault
+- Bob's balance increases, note is nullified
+
+### Benefits
+
+This approach provides several advantages over direct transfers:
+
+1. **Privacy**: Alice and Bob's transactions are unlinkable
+2. **Parallelization**: Multiple transactions can be processed concurrently, enabling simultaneous creation of notes.
+3. **Flexibility**: Notes can include complex conditions (time locks, multi-sig, etc.)
+4. **Scalability**: No global state synchronization required
+
+## Set Up Development Environment
+
+To run the code examples in this guide, you'll need to set up a development environment. If you haven't already, follow the setup instructions in the [Accounts](./accounts#set-up-development-environment) guide.
+
+:::note Transaction fees
+Each account that submits a transaction needs native fee tokens, including a faucet that mints `TEST`. The bootstrap below pauses each program so you can fund a newly created account before its first transaction.
+:::
+
+### Bootstrap native fee funding
+
+Add the following shared helpers once, then use them with each program on this page. In the Rust project, add `pub mod funding;` to the existing `integration/src/lib.rs` file.
+
+Start a program and keep its terminal or browser tab running after it prints the account IDs. For each prompt, request a **public** native-fee funding note for that displayed account from a trusted faucet on the same network, then paste only the funding note ID. Do not consume the note with the CLI or another client store: the running program must discover and consume it. Request enough native tokens to cover this bootstrap transaction and the account's later transactions in the example; the required amount depends on network fees.
+
+```rust title="integration/src/funding.rs"
+use miden_client::{
+ account::AccountId,
+ auth::TransactionAuthenticator,
+ note::{Note, NoteId},
+ store::TransactionFilter,
+ transaction::{TransactionRequestBuilder, TransactionStatus},
+ Client,
+};
+use std::io::{self, Write};
+use tokio::time::{sleep, Duration, Instant};
+
+const WAIT_TIMEOUT: Duration = Duration::from_secs(300);
+
+pub async fn fund_account(
+ client: &mut Client,
+ account_id: AccountId,
+) -> anyhow::Result<()>
+where
+ AUTH: TransactionAuthenticator + Sync + 'static,
+{
+ println!("Request a public native-fee funding note for {account_id}.");
+ print!("Paste its note ID here: ");
+ io::stdout().flush()?;
+
+ let mut note_id = String::new();
+ io::stdin().read_line(&mut note_id)?;
+ let note_id = NoteId::try_from_hex(note_id.trim())?;
+
+ let deadline = Instant::now() + WAIT_TIMEOUT;
+ let funding_note: Note = loop {
+ client.sync_state().await?;
+ let notes = client.get_consumable_notes(Some(account_id)).await?;
+
+ if let Some((record, _)) = notes
+ .into_iter()
+ .find(|(record, _)| record.id() == Some(note_id))
+ {
+ break record.try_into()?;
+ }
+
+ if Instant::now() >= deadline {
+ anyhow::bail!(
+ "Timed out waiting for funding note {note_id}; verify its ID, visibility, and network"
+ );
+ }
+
+ sleep(Duration::from_secs(2)).await;
+ };
+
+ let request = TransactionRequestBuilder::new().build_consume_notes(vec![funding_note])?;
+ let tx_id = client.submit_new_transaction(account_id, request).await?;
+ println!("Native funding transaction submitted, ID: {}", tx_id.to_hex());
+
+ let deadline = Instant::now() + WAIT_TIMEOUT;
+ loop {
+ client.sync_state().await?;
+ let records = client
+ .get_transactions(TransactionFilter::Ids(vec![tx_id]))
+ .await?;
+
+ if let Some(record) = records.first() {
+ match &record.status {
+ TransactionStatus::Committed { .. } => {
+ println!("Native funding confirmed for {account_id}");
+ return Ok(());
+ },
+ TransactionStatus::Discarded(cause) => {
+ anyhow::bail!("Native funding transaction was discarded: {cause}");
+ },
+ TransactionStatus::Pending => {},
+ }
+ }
+
+ if Instant::now() >= deadline {
+ anyhow::bail!(
+ "Timed out waiting for transaction {} to commit; check this ID before retrying",
+ tx_id.to_hex()
+ );
+ }
+
+ sleep(Duration::from_secs(2)).await;
+ }
+}
+```
+
+```typescript title="src/funding.ts"
+import {
+ type AccountId,
+ MidenClient,
+ NoteId,
+} from "@miden-sdk/miden-sdk";
+
+const WAIT_TIMEOUT_MS = 300_000;
+
+export async function fundAccount(
+ client: MidenClient,
+ accountId: AccountId,
+): Promise {
+ console.log(
+ `Request a public native-fee funding note for ${accountId.toString()}.`,
+ );
+ const enteredId = window.prompt("Paste its note ID here:");
+ if (!enteredId?.trim()) {
+ throw new Error("A native funding note ID is required");
+ }
+
+ const noteId = NoteId.fromHex(enteredId.trim());
+ const normalizedId = noteId.toString();
+ const deadline = Date.now() + WAIT_TIMEOUT_MS;
+
+ while (Date.now() < deadline) {
+ await client.sync();
+ const notes = await client.notes.listAvailable({ account: accountId });
+ const fundingNote = notes.find(
+ (note) => note.id()?.toString() === normalizedId,
+ );
+
+ if (fundingNote) {
+ const { txId } = await client.transactions.consume({
+ account: accountId,
+ notes: [fundingNote],
+ });
+ console.log(
+ "Native funding transaction submitted, ID:",
+ txId.toHex(),
+ );
+ await client.transactions.waitFor(txId, {
+ timeout: WAIT_TIMEOUT_MS,
+ });
+ await client.sync();
+ console.log("Native funding confirmed for", accountId.toString());
+ return;
+ }
+
+ await new Promise((resolve) => window.setTimeout(resolve, 2_000));
+ }
+
+ throw new Error(
+ `Timed out waiting for funding note ${normalizedId}; verify its ID, visibility, and network`,
+ );
+}
+```
+
+## Minting Tokens
+
+**What is Minting?**
+Minting in Miden creates new tokens and packages them into a **P2ID note** (Pay-to-ID note) addressed to a specific account. Unlike traditional blockchains where tokens appear directly in your balance, Miden uses a two-step process:
+
+1. **Faucet mints tokens** → Creates a P2ID note containing the tokens
+2. **Recipient consumes the note** → Tokens move into their account vault
+
+**Key Concepts:**
+
+- **P2ID Note**: A note that can only be consumed by the account it's addressed to
+- **NoteType**: Determines visibility. `Public` note details are stored onchain. `Private` note details must reach the consumer separately; their commitments, metadata, and attachments remain public.
+- **FungibleAsset**: Represents tokens that can be divided and exchanged (like currencies)
+
+Let's see this in action:
+
+```rust title="integration/src/bin/mint.rs"
+use miden_client::{
+ account::{
+ component::{
+ AuthScheme, AuthSingleSig, BasicWallet, BurnPolicy, FungibleFaucet, MintPolicy,
+ TokenName, TokenPolicyManager, TransferPolicy,
+ create_singlesig_user_fungible_faucet,
+ },
+ AccountBuilder, AccountType,
+ },
+ asset::{AssetAmount, FungibleAsset, TokenSymbol},
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ note::NoteType,
+ rpc::Endpoint,
+ transaction::TransactionRequestBuilder,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+use integration::funding::fund_account;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ //------------------------------------------------------------
+ // CREATING A FAUCET AND MINTING TOKENS
+ //------------------------------------------------------------
+
+ // Account seeds
+ let mut alice_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut alice_seed);
+ let mut faucet_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut faucet_seed);
+
+ // Faucet parameters
+ let symbol = TokenSymbol::new("TEST")?;
+ let decimals = 8;
+ let max_supply = AssetAmount::from(1_000_000u32);
+
+ // Generate key pair
+ let alice_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+ let faucet_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ // Build the account
+ let account_builder = AccountBuilder::new(alice_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ alice_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet);
+
+ // Build the faucet
+ let faucet = FungibleFaucet::builder()
+ .name(TokenName::new("Test Token")?)
+ .symbol(symbol)
+ .decimals(decimals)
+ .max_supply(max_supply)
+ .build()?;
+ let policies = TokenPolicyManager::builder()
+ .active_mint_policy(MintPolicy::allow_all())
+ .active_burn_policy(BurnPolicy::allow_all())
+ .active_send_policy(TransferPolicy::allow_all())
+ .active_receive_policy(TransferPolicy::allow_all())
+ .build();
+
+ let alice_account = account_builder.build()?;
+ let faucet_auth = AuthSingleSig::new(Approver::new(
+ faucet_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ ));
+ let faucet_account = create_singlesig_user_fungible_faucet(
+ faucet_seed,
+ faucet,
+ faucet_auth,
+ policies,
+ AccountType::Public,
+ )?;
+
+ println!("Alice's account ID: {}", alice_account.id().to_hex());
+ println!("Faucet account ID: {}", faucet_account.id().to_hex());
+
+ // Add accounts to client
+ client.add_account(&alice_account, false).await?;
+ client.add_account(&faucet_account, false).await?;
+
+ // Add keys to keystore
+ keystore.add_key(&alice_key_pair, alice_account.id()).await?;
+ keystore.add_key(&faucet_key_pair, faucet_account.id()).await?;
+
+ // Keep this program running while you fund the faucet at the prompt.
+ fund_account(&mut client, faucet_account.id()).await?;
+
+ let amount: u64 = 1000;
+ // The faucet account ID encodes callback support for the transfer policies above.
+ let fungible_asset = FungibleAsset::new(faucet_account.id(), amount)?;
+
+ // Build transaction request to mint fungible asset to Alice's account
+ // NOTE: This transaction will create a P2ID note (a Miden note containing the minted asset)
+ // for Alice's account. Alice will be able to consume these notes to get the fungible asset in her vault
+ println!("Minting 1000 tokens to Alice...");
+ let transaction_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
+ fungible_asset,
+ alice_account.id(),
+ NoteType::Public,
+ client.rng(),
+ )?;
+
+ // Create transaction and submit it to create P2ID notes for Alice's account
+ let tx_id = client
+ .submit_new_transaction(faucet_account.id(), transaction_request)
+ .await?;
+ client.sync_state().await?;
+
+ println!(
+ "Mint transaction submitted successfully, ID: {}",
+ tx_id.to_hex()
+ );
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+import { fundAccount } from "./funding";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // Creating Alice's account
+ const alice = await client.accounts.create({
+ storage: "public", // Public: account state is visible onchain
+ });
+ console.log("Alice's account ID:", alice.id().toString());
+
+ // Creating a faucet account
+ const decimals = 8;
+ const maxSupply = 10_000_000n * 10n ** BigInt(decimals);
+ const faucet = await client.accounts.create({
+ type: 0, // Fungible faucet
+ symbol: "TEST",
+ decimals,
+ maxSupply,
+ storage: "public",
+ });
+ console.log("Faucet account ID:", faucet.id().toString());
+
+ // Keep this browser tab running while you fund the faucet at the prompt.
+ await fundAccount(client, faucet.id());
+
+ // Mint 1000 tokens to Alice.
+ // This creates a P2ID note containing the asset; Alice consumes it
+ // to actually receive the tokens in her vault (see the next section).
+ console.log("Minting 1000 tokens to Alice...");
+ const { txId } = await client.transactions.mint({
+ account: faucet.id(), // faucet is the executing account
+ to: alice.id(),
+ amount: 1000n,
+ type: "public", // note visibility
+ });
+ console.log("Mint transaction submitted successfully, ID:", txId.toHex());
+}
+```
+
+
+Expected output
+
+```text
+Alice's account ID: 0x5b2840a923dedc102ea67e0c1eba3c
+Faucet account ID: 0x29dd1dc628d2842032e751ed1b5da7
+Minting 1000 tokens to Alice...
+Mint transaction submitted successfully, ID: 0x7a2dbde87ea2f4d41b396d6d3f6bdb9a8d7e2a51555fa57064a1657ad70fca06
+```
+
+
+
+## Consuming Notes
+
+**Why Consume Notes?**
+After minting creates a P2ID note containing tokens, the recipient must **consume** the note to actually receive the tokens in their account vault. This two-step process provides several benefits:
+
+- **Privacy**: The mint transaction and consume transaction are unlinkable
+- **Flexibility**: Recipients can consume notes when they choose
+- **Atomic Operations**: Each step either succeeds completely or fails safely
+
+**The Process:**
+
+1. **Find consumable notes** addressed to your account
+2. **Create a consume transaction** referencing the note IDs
+3. **Submit the transaction** to move tokens into your vault
+
+Here's how to consume notes programmatically:
+
+:::tip
+This program includes the setup and minting steps from the previous section and requires the native fee funding described above. **The new consume logic starts at the `CONSUMING P2ID NOTES` comment.**
+:::
+
+```rust title="integration/src/bin/consume.rs"
+use miden_client::{
+ account::{
+ component::{
+ AuthScheme, AuthSingleSig, BasicWallet, BurnPolicy, FungibleFaucet, MintPolicy,
+ TokenName, TokenPolicyManager, TransferPolicy,
+ create_singlesig_user_fungible_faucet,
+ },
+ Account, AccountBuilder, AccountType,
+ },
+ asset::{AssetAmount, AssetId, FungibleAsset, TokenSymbol},
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ note::NoteType,
+ rpc::Endpoint,
+ transaction::TransactionRequestBuilder,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+use tokio::time::Duration;
+use integration::funding::fund_account;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ //------------------------------------------------------------
+ // CREATING A FAUCET AND MINTING TOKENS
+ //------------------------------------------------------------
+
+ // Account seeds
+ let mut alice_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut alice_seed);
+ let mut faucet_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut faucet_seed);
+
+ // Faucet parameters
+ let symbol = TokenSymbol::new("TEST")?;
+ let decimals = 8;
+ let max_supply = AssetAmount::from(1_000_000u32);
+
+ // Generate key pair
+ let alice_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+ let faucet_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ // Build the account
+ let account_builder = AccountBuilder::new(alice_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ alice_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet);
+
+ // Build the faucet
+ let faucet = FungibleFaucet::builder()
+ .name(TokenName::new("Test Token")?)
+ .symbol(symbol)
+ .decimals(decimals)
+ .max_supply(max_supply)
+ .build()?;
+ let policies = TokenPolicyManager::builder()
+ .active_mint_policy(MintPolicy::allow_all())
+ .active_burn_policy(BurnPolicy::allow_all())
+ .active_send_policy(TransferPolicy::allow_all())
+ .active_receive_policy(TransferPolicy::allow_all())
+ .build();
+
+ let alice_account = account_builder.build()?;
+ let faucet_auth = AuthSingleSig::new(Approver::new(
+ faucet_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ ));
+ let faucet_account = create_singlesig_user_fungible_faucet(
+ faucet_seed,
+ faucet,
+ faucet_auth,
+ policies,
+ AccountType::Public,
+ )?;
+
+ println!("Alice's account ID: {}", alice_account.id().to_hex());
+ println!("Faucet account ID: {}", faucet_account.id().to_hex());
+
+ // Add accounts to client
+ client.add_account(&alice_account, false).await?;
+ client.add_account(&faucet_account, false).await?;
+
+ // Add keys to keystore
+ keystore.add_key(&alice_key_pair, alice_account.id()).await?;
+ keystore.add_key(&faucet_key_pair, faucet_account.id()).await?;
+
+ // The faucet mints; Alice later consumes. Fund both before either transaction.
+ fund_account(&mut client, faucet_account.id()).await?;
+ fund_account(&mut client, alice_account.id()).await?;
+
+ let amount: u64 = 1000;
+ // The faucet account ID encodes callback support for the transfer policies above.
+ let fungible_asset = FungibleAsset::new(faucet_account.id(), amount)?;
+
+ // Build transaction request to mint fungible asset to Alice's account
+ // NOTE: This transaction will create a P2ID note (a Miden note containing the minted asset)
+ // for Alice's account. Alice will be able to consume these notes to get the fungible asset in her vault
+ println!("Minting 1000 tokens to Alice...");
+ let transaction_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
+ fungible_asset,
+ alice_account.id(),
+ NoteType::Public,
+ client.rng(),
+ )?;
+
+ // Create transaction and submit it to create P2ID notes for Alice's account
+ let tx_id = client
+ .submit_new_transaction(faucet_account.id(), transaction_request)
+ .await?;
+ client.sync_state().await?;
+
+ println!(
+ "Mint transaction submitted successfully, ID: {}",
+ tx_id.to_hex()
+ );
+
+ //------------------------------------------------------------
+ // CONSUMING P2ID NOTES
+ //------------------------------------------------------------
+
+ // Public notes must be committed to a block before they can be consumed.
+ // Poll until the network includes our mint note in a block.
+ println!("Waiting for note to be consumable...");
+ loop {
+ // Sync state to get the latest block
+ client.sync_state().await?;
+
+ let consumable_notes = client
+ .get_consumable_notes(Some(alice_account.id()))
+ .await?;
+
+ if consumable_notes.is_empty() {
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ continue;
+ }
+
+ let notes: Vec = consumable_notes
+ .into_iter()
+ .map(|(record, _)| record.try_into().expect("Failed to convert to Note"))
+ .collect();
+
+ let consume_tx_request = TransactionRequestBuilder::new().build_consume_notes(notes)?;
+
+ // Create transaction and submit it to consume notes
+ let consume_tx_id = client
+ .submit_new_transaction(alice_account.id(), consume_tx_request)
+ .await?;
+
+ println!(
+ "Consume transaction submitted successfully, ID: {}",
+ consume_tx_id.to_hex()
+ );
+
+ client.sync_state().await?;
+
+ let alice_account: Account = client
+ .get_account(alice_account.id())
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("Account not found"))?
+ .try_into()?;
+ let vault = alice_account.vault();
+ let asset_id = AssetId::new_fungible(faucet_account.id());
+ println!(
+ "Alice's TEST token balance: {}",
+ vault.get_balance(asset_id)?
+ );
+
+ break; // Exit the loop after consuming the note
+ }
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+import { fundAccount } from "./funding";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // Creating Alice's account
+ const alice = await client.accounts.create({
+ storage: "public",
+ });
+ console.log("Alice's account ID:", alice.id().toString());
+
+ // Creating a faucet account
+ const decimals = 8;
+ const maxSupply = 10_000_000n * 10n ** BigInt(decimals);
+ const faucet = await client.accounts.create({
+ type: 0, // Fungible faucet
+ symbol: "TEST",
+ decimals,
+ maxSupply,
+ storage: "public",
+ });
+ console.log("Faucet account ID:", faucet.id().toString());
+
+ // The faucet mints; Alice later consumes. Fund both before either transaction.
+ await fundAccount(client, faucet.id());
+ await fundAccount(client, alice.id());
+
+ // Mint 1000 tokens to Alice. Creates a P2ID note that she'll consume.
+ console.log("Minting 1000 tokens to Alice...");
+ const mintResult = await client.transactions.mint({
+ account: faucet.id(),
+ to: alice.id(),
+ amount: 1000n,
+ type: "public",
+ waitForConfirmation: true,
+ });
+ console.log(
+ "Mint transaction submitted successfully, ID:",
+ mintResult.txId.toHex(),
+ );
+
+ // List notes available to Alice and consume them — tokens move into her vault.
+ console.log("Waiting for note to be consumable...");
+ const notes = await client.notes.listAvailable({ account: alice.id() });
+ const consumeResult = await client.transactions.consume({
+ account: alice.id(),
+ notes: [notes[0]],
+ waitForConfirmation: true,
+ });
+ console.log(
+ "Consume transaction submitted successfully, ID:",
+ consumeResult.txId.toHex(),
+ );
+
+ // Fetch Alice again so her vault reflects the consumed note.
+ const updatedAlice = await client.accounts.get(alice.id());
+ if (!updatedAlice) {
+ throw new Error("Alice's account was not found");
+ }
+
+ const balance = updatedAlice.vault().getBalance(faucet.id());
+ console.log("Alice's TEST token balance:", Number(balance));
+}
+```
+
+
+Expected output
+
+```text
+Alice's account ID: 0x5b2840a923dedc102ea67e0c1eba3c
+Faucet account ID: 0x29dd1dc628d2842032e751ed1b5da7
+Minting 1000 tokens to Alice...
+Mint transaction submitted successfully, ID: 0x7a2dbde87ea2f4d41b396d6d3f6bdb9a8d7e2a51555fa57064a1657ad70fca06
+Waiting for note to be consumable...
+Consume transaction submitted successfully, ID: 0xa75872c498ee71cd6725aef9411d2559094cec1e1e89670dbf99c60bb8843481
+Alice's TEST token balance: 1000
+```
+
+
+
+## Sending Tokens Between Accounts
+
+**How Sending Works in Miden**
+Sending tokens between accounts follows the same note-based pattern. The sender creates a new P2ID note containing tokens from their vault and addresses it to the recipient:
+
+**The Flow:**
+
+1. **Sender creates P2ID note** containing tokens and recipient's account ID
+2. **Sender submits transaction** - their balance decreases, note is published
+3. **Recipient discovers note** addressed to their account ID
+4. **Recipient consumes note** - tokens move into their vault
+
+Alice and Bob execute separate transactions. Their visibility depends on the
+account and note types they use; public notes expose their details.
+
+Let's implement the complete flow - mint, consume, then send:
+
+:::tip
+This program includes all previous steps and requires the native fee funding described above. **The new send logic starts at the `SENDING TOKENS TO BOB` comment.**
+:::
+
+```rust title="integration/src/bin/send.rs"
+use miden_client::{
+ account::{
+ component::{
+ AuthScheme, AuthSingleSig, BasicWallet, BurnPolicy, FungibleFaucet, MintPolicy,
+ TokenName, TokenPolicyManager, TransferPolicy,
+ create_singlesig_user_fungible_faucet,
+ },
+ Account, AccountBuilder, AccountType,
+ },
+ asset::{AssetAmount, AssetId, FungibleAsset, TokenSymbol},
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ note::{NoteType, P2idNote},
+ rpc::Endpoint,
+ transaction::TransactionRequestBuilder,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+use tokio::time::Duration;
+use integration::funding::fund_account;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ //------------------------------------------------------------
+ // CREATING A FAUCET AND MINTING TOKENS
+ //------------------------------------------------------------
+
+ // Account seeds
+ let mut alice_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut alice_seed);
+ let mut faucet_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut faucet_seed);
+
+ // Faucet parameters
+ let symbol = TokenSymbol::new("TEST")?;
+ let decimals = 8;
+ let max_supply = AssetAmount::from(1_000_000u32);
+
+ // Generate key pair
+ let alice_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+ let faucet_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ // Build the account
+ let account_builder = AccountBuilder::new(alice_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ alice_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet);
+
+ // Build the faucet
+ let faucet = FungibleFaucet::builder()
+ .name(TokenName::new("Test Token")?)
+ .symbol(symbol)
+ .decimals(decimals)
+ .max_supply(max_supply)
+ .build()?;
+ let policies = TokenPolicyManager::builder()
+ .active_mint_policy(MintPolicy::allow_all())
+ .active_burn_policy(BurnPolicy::allow_all())
+ .active_send_policy(TransferPolicy::allow_all())
+ .active_receive_policy(TransferPolicy::allow_all())
+ .build();
+
+ let alice_account = account_builder.build()?;
+ let faucet_auth = AuthSingleSig::new(Approver::new(
+ faucet_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ ));
+ let faucet_account = create_singlesig_user_fungible_faucet(
+ faucet_seed,
+ faucet,
+ faucet_auth,
+ policies,
+ AccountType::Public,
+ )?;
+
+ println!("Alice's account ID: {}", alice_account.id().to_hex());
+ println!("Faucet account ID: {}", faucet_account.id().to_hex());
+
+ // Add accounts to client
+ client.add_account(&alice_account, false).await?;
+ client.add_account(&faucet_account, false).await?;
+
+ // Add keys to keystore
+ keystore.add_key(&alice_key_pair, alice_account.id()).await?;
+ keystore.add_key(&faucet_key_pair, faucet_account.id()).await?;
+
+ // The faucet mints; Alice later consumes and sends. Fund both first.
+ fund_account(&mut client, faucet_account.id()).await?;
+ fund_account(&mut client, alice_account.id()).await?;
+
+ let amount: u64 = 1000;
+ // The faucet account ID encodes callback support for the transfer policies above.
+ let fungible_asset = FungibleAsset::new(faucet_account.id(), amount)?;
+
+ // Build transaction request to mint fungible asset to Alice's account
+ // NOTE: This transaction will create a P2ID note (a Miden note containing the minted asset)
+ // for Alice's account. Alice will be able to consume these notes to get the fungible asset in her vault
+ println!("Minting 1000 tokens to Alice...");
+ let transaction_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
+ fungible_asset,
+ alice_account.id(),
+ NoteType::Public,
+ client.rng(),
+ )?;
+
+ // Create transaction and submit it to create P2ID notes for Alice's account
+ let tx_id = client
+ .submit_new_transaction(faucet_account.id(), transaction_request)
+ .await?;
+ client.sync_state().await?;
+
+ println!(
+ "Mint transaction submitted successfully, ID: {}",
+ tx_id.to_hex()
+ );
+
+ //------------------------------------------------------------
+ // CONSUMING P2ID NOTES
+ //------------------------------------------------------------
+
+ // Public notes must be committed to a block before they can be consumed.
+ // Poll until the network includes our mint note in a block.
+ println!("Waiting for note to be consumable...");
+ loop {
+ // Sync state to get the latest block
+ client.sync_state().await?;
+
+ let consumable_notes = client
+ .get_consumable_notes(Some(alice_account.id()))
+ .await?;
+
+ if consumable_notes.is_empty() {
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ continue;
+ }
+
+ let notes: Vec = consumable_notes
+ .into_iter()
+ .map(|(record, _)| record.try_into().expect("Failed to convert to Note"))
+ .collect();
+
+ let consume_tx_request = TransactionRequestBuilder::new().build_consume_notes(notes)?;
+
+ // Create transaction and submit it to consume notes
+ let consume_tx_id = client
+ .submit_new_transaction(alice_account.id(), consume_tx_request)
+ .await?;
+
+ println!(
+ "Consume transaction submitted successfully, ID: {}",
+ consume_tx_id.to_hex()
+ );
+
+ client.sync_state().await?;
+
+ let alice_account: Account = client
+ .get_account(alice_account.id())
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("Account not found"))?
+ .try_into()?;
+ let vault = alice_account.vault();
+ let asset_id = AssetId::new_fungible(faucet_account.id());
+ println!(
+ "Alice's TEST token balance: {}",
+ vault.get_balance(asset_id)?
+ );
+
+ break; // Exit the loop after consuming the note
+ }
+
+ //------------------------------------------------------------
+ // SENDING TOKENS TO BOB
+ //------------------------------------------------------------
+
+ // Create Bob's account so this example is self-contained.
+ let mut bob_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut bob_seed);
+ let bob_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+ let bob_account = AccountBuilder::new(bob_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ bob_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet)
+ .build()?;
+
+ client.add_account(&bob_account, false).await?;
+ keystore.add_key(&bob_key_pair, bob_account.id()).await?;
+
+ println!("Bob's account ID: {}", bob_account.id().to_hex());
+
+ let bob_account_id = bob_account.id();
+ let send_amount = 100;
+ let fungible_asset_to_send = FungibleAsset::new(faucet_account.id(), send_amount)?;
+
+ println!("Sending 100 tokens to Bob...");
+ let p2id_note = P2idNote::builder()
+ .sender(alice_account.id())
+ .target(bob_account_id)
+ .asset(fungible_asset_to_send)
+ .note_type(NoteType::Public)
+ .generate_serial_number(client.rng())
+ .build()?
+ .into();
+
+ // Create transaction request to send P2ID note to Bob
+ let send_p2id_note_transaction_request = TransactionRequestBuilder::new()
+ .own_output_notes(vec![p2id_note])
+ .build()?;
+
+ // Create transaction and submit it to send P2ID note to Bob
+ let send_p2id_note_tx_id = client
+ .submit_new_transaction(alice_account.id(), send_p2id_note_transaction_request)
+ .await?;
+ client.sync_state().await?;
+
+ println!(
+ "Send transaction submitted successfully, ID: {}",
+ send_p2id_note_tx_id.to_hex()
+ );
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+import { fundAccount } from "./funding";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // Create Alice's account and a faucet.
+ const alice = await client.accounts.create({
+ storage: "public",
+ });
+ console.log("Alice's account ID:", alice.id().toString());
+
+ const decimals = 8;
+ const maxSupply = 10_000_000n * 10n ** BigInt(decimals);
+ const faucet = await client.accounts.create({
+ type: 0, // Fungible faucet
+ symbol: "TEST",
+ decimals,
+ maxSupply,
+ storage: "public",
+ });
+ console.log("Faucet account ID:", faucet.id().toString());
+
+ // The faucet mints; Alice later consumes and sends. Fund both first.
+ await fundAccount(client, faucet.id());
+ await fundAccount(client, alice.id());
+
+ // Mint 1000 tokens to Alice and consume the resulting P2ID note.
+ console.log("Minting 1000 tokens to Alice...");
+ const mintResult = await client.transactions.mint({
+ account: faucet.id(),
+ to: alice.id(),
+ amount: 1000n,
+ type: "public",
+ waitForConfirmation: true,
+ });
+ console.log(
+ "Mint transaction submitted successfully, ID:",
+ mintResult.txId.toHex(),
+ );
+
+ console.log("Waiting for note to be consumable...");
+ const notes = await client.notes.listAvailable({ account: alice.id() });
+ const consumeResult = await client.transactions.consume({
+ account: alice.id(),
+ notes: [notes[0]],
+ waitForConfirmation: true,
+ });
+ console.log(
+ "Consume transaction submitted successfully, ID:",
+ consumeResult.txId.toHex(),
+ );
+
+ // Fetch Alice again so her vault reflects the consumed note.
+ const updatedAlice = await client.accounts.get(alice.id());
+ if (!updatedAlice) {
+ throw new Error("Alice's account was not found");
+ }
+
+ const balance = updatedAlice.vault().getBalance(faucet.id());
+ console.log("Alice's TEST token balance:", Number(balance));
+
+ // Create Bob's account so this example is self-contained.
+ const bob = await client.accounts.create({
+ storage: "public",
+ });
+ console.log("Bob's account ID:", bob.id().toString());
+
+ // Send 100 tokens from Alice to Bob.
+ console.log("Sending 100 tokens to Bob...");
+ const { txId } = await client.transactions.send({
+ account: alice.id(),
+ to: bob.id(),
+ token: faucet.id(),
+ amount: 100n,
+ type: "public",
+ waitForConfirmation: true,
+ });
+ console.log("Send transaction submitted successfully, ID:", txId.toHex());
+}
+```
+
+
+Expected output
+
+```text
+Alice's account ID: 0xd6b8bb0ed10b1610282c513501778a
+Faucet account ID: 0xe48c43d6ad6496201bcfa585a5a4b6
+Minting 1000 tokens to Alice...
+Mint transaction submitted successfully, ID: 0x948a0eef754068b3126dd3261b6b54214fa5608fb13c5e5953faf59bad79c75f
+Waiting for note to be consumable...
+Consume transaction submitted successfully, ID: 0xc69ab84b784120abe858bb536aebda90bd2067695f11d5da93ab0b704f39ad78
+Alice's TEST token balance: 1000
+Bob's account ID: 0x103f8a1ad4b983104aec0412ab0b0d
+Sending 100 tokens to Bob...
+Send transaction submitted successfully, ID: 0x51ac27474ade3a54adadd50db6c2b9a2ede254c5f9137f93d7a970f0bc7d66d5
+```
+
+
+
+## Key Takeaways
+
+**Miden's Note-Based Transaction Model:**
+
+- **Notes** enable asset transfers between accounts (both public and private)
+- **Two-transaction model** provides privacy and parallelization benefits
+- **Zero-knowledge proofs** validate transaction execution without revealing details
+- **P2ID notes** target specific recipients using their account IDs
+
+**Transaction Flow:**
+
+1. **Mint** tokens to create notes containing assets
+2. **Consume** notes to add assets to account vaults
+3. **Send** tokens using P2ID notes targeted to recipients
+4. **Nullify** consumed notes to prevent double-spending
+
+This innovative approach provides unprecedented privacy and flexibility while maintaining the security guarantees of blockchain technology. The note-based model enables scalable, private transactions that can be processed in parallel without global state synchronization.
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/read-storage.md b/versioned_docs/version-0.16/builder/get-started/read-storage.md
new file mode 100644
index 00000000..eca9f106
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/read-storage.md
@@ -0,0 +1,400 @@
+---
+sidebar_position: 4
+title: Read Storage Values
+description: Learn how to query account storage data and interact with deployed smart contracts.
+---
+
+# Read Storage Values
+
+Let's explore how to interact with public accounts and retrieve their storage data.
+
+## Understanding Account Storage
+
+Miden accounts contain several types of data you can read.
+
+**Account Components:**
+
+- **Vault**: Contains the account's assets (tokens)
+- **Storage**: Key-value data store with up to 255 slots
+- **Code**: The account's smart contract logic (MAST root)
+- **Nonce**: Nonce that increments with each state change to prevent double spend
+
+**Storage Visibility:**
+
+- **Public accounts**: All data is publicly accessible and can be read by anyone
+- **Private accounts**: Only commitments are public; full data is held privately
+
+## Set Up Development Environment
+
+To run the code examples in this guide, you'll need to set up a development environment. If you haven't already, follow the setup instructions in the [Accounts](./accounts#set-up-development-environment) guide.
+
+## Reading from a Public Smart Contract
+
+Let's read a public counter contract already deployed on the Miden testnet. The examples below include its account ID, so you can query its storage without deploying a contract. This counter stores its value in a named storage map slot.
+
+### Reading the Count of a Counter contract
+
+Run `cargo run --bin read-count` for Rust, or call `demo()` for TypeScript.
+
+```rust title="integration/src/bin/read-count.rs"
+use miden_client::{
+ account::{Account, AccountId, StorageMapKey, StorageSlotName},
+ builder::ClientBuilder,
+ keystore::FilesystemKeyStore,
+ rpc::Endpoint,
+ Felt, Word,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use std::sync::Arc;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ //------------------------------------------------------------
+ // READ PUBLIC STATE OF THE COUNTER ACCOUNT
+ //------------------------------------------------------------
+
+ // A counter contract deployed on the Miden testnet. It is a public fixture and
+ // may need updating after a new release.
+ let counter_account_id = AccountId::from_hex("0x78ccf49bd142f8917edf7743e3e718")?;
+
+ client.import_account_by_id(counter_account_id).await?;
+
+ let counter_account: Account = client
+ .get_account(counter_account_id)
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("Account not found"))?
+ .try_into()?;
+
+ // Read the count using the contract's storage slot name and map key.
+ let slot_name = StorageSlotName::new("counter_account::counter_contract::count_map")?;
+ let counter_key = Word::new([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ONE]);
+ let count = counter_account
+ .storage()
+ .get_map_item(
+ &slot_name,
+ StorageMapKey::new(counter_key),
+ )?;
+
+ println!("Count: {}", count[0].as_canonical_u64());
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient, Word } from "@miden-sdk/miden-sdk";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // A counter contract deployed on the Miden testnet. It is a public fixture and
+ // may need updating after a new release.
+ const counterAccountId = "0x78ccf49bd142f8917edf7743e3e718";
+
+ // Fetch the counter account (imports it into the local store if needed).
+ const counter = await client.accounts.getOrImport(counterAccountId);
+
+ // Get the count from the counter account by querying its storage map
+ // using the named storage slot and counter key.
+ const slotName = "counter_account::counter_contract::count_map";
+ const counterKey = new Word(BigUint64Array.from([0n, 0n, 0n, 1n]));
+ const count = counter.storage().getMapItem(slotName, counterKey);
+
+ // The count value is a WORD (array of 4 u64 values).
+ // The counter number is the first element.
+ console.log("Count:", Number(count?.toU64s()[0]));
+}
+```
+
+
+Expected output
+
+```text
+Count: 1
+```
+
+
+
+## Reading Account Token Balances
+
+You can also query the assets (tokens) held by an account. This example uses the native-fee funding helpers from [Notes & Transactions](./notes#bootstrap-native-fee-funding). Add those helpers first, then keep the program running while you fund the faucet and Alice at their prompts.
+
+```rust title="integration/src/bin/token-balance.rs"
+use miden_client::{
+ account::{
+ component::{
+ AuthScheme, AuthSingleSig, BasicWallet, BurnPolicy, FungibleFaucet, MintPolicy,
+ TokenName, TokenPolicyManager, TransferPolicy,
+ create_singlesig_user_fungible_faucet,
+ },
+ Account, AccountBuilder, AccountType,
+ },
+ asset::{AssetAmount, AssetId, FungibleAsset, TokenSymbol},
+ auth::{Approver, AuthSecretKey},
+ builder::ClientBuilder,
+ keystore::{FilesystemKeyStore, Keystore},
+ note::NoteType,
+ rpc::Endpoint,
+ transaction::TransactionRequestBuilder,
+};
+use miden_client_sqlite_store::ClientBuilderSqliteExt;
+use rand::Rng;
+use std::sync::Arc;
+use tokio::time::Duration;
+use integration::funding::fund_account;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ // Initialize RPC connection
+ let endpoint = Endpoint::testnet();
+ let timeout_ms = 10_000;
+
+ // Initialize keystore
+ let keystore_path = std::path::PathBuf::from("./keystore");
+ let keystore =
+ Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
+
+ let store_path = std::path::PathBuf::from("./store.sqlite3");
+
+ // Initialize client to connect with the Miden Testnet.
+ // NOTE: The client is our entry point to the Miden network.
+ // All interactions with the network go through the client.
+ let mut client = ClientBuilder::new()
+ .grpc_client(&endpoint, Some(timeout_ms))
+ .sqlite_store(store_path)
+ .authenticator(keystore.clone())
+ .build()
+ .await?;
+
+ client.sync_state().await?;
+
+ //------------------------------------------------------------
+ // CREATING A FAUCET, MINTING AND CONSUMING TOKENS
+ //------------------------------------------------------------
+
+ // Account seeds
+ let mut alice_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut alice_seed);
+ let mut faucet_seed = [0u8; 32];
+ client.rng().fill_bytes(&mut faucet_seed);
+
+ // Faucet parameters
+ let symbol = TokenSymbol::new("TEST")?;
+ let decimals = 8;
+ let max_supply = AssetAmount::from(1_000_000u32);
+
+ // Generate key pair
+ let alice_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+ let faucet_key_pair = AuthSecretKey::new_falcon512_poseidon2();
+
+ // Build the account
+ let account_builder = AccountBuilder::new(alice_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(
+ alice_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ )))
+ .with_component(BasicWallet);
+
+ // Build the faucet
+ let faucet = FungibleFaucet::builder()
+ .name(TokenName::new("Test Token")?)
+ .symbol(symbol)
+ .decimals(decimals)
+ .max_supply(max_supply)
+ .build()?;
+ let policies = TokenPolicyManager::builder()
+ .active_mint_policy(MintPolicy::allow_all())
+ .active_burn_policy(BurnPolicy::allow_all())
+ .active_send_policy(TransferPolicy::allow_all())
+ .active_receive_policy(TransferPolicy::allow_all())
+ .build();
+
+ let alice_account = account_builder.build()?;
+ let faucet_auth = AuthSingleSig::new(Approver::new(
+ faucet_key_pair.public_key().to_commitment(),
+ AuthScheme::Falcon512Poseidon2,
+ ));
+ let faucet_account = create_singlesig_user_fungible_faucet(
+ faucet_seed,
+ faucet,
+ faucet_auth,
+ policies,
+ AccountType::Public,
+ )?;
+
+ println!("Alice's account ID: {}", alice_account.id().to_hex());
+ println!("Faucet account ID: {}", faucet_account.id().to_hex());
+
+ // Add accounts to client
+ client.add_account(&alice_account, false).await?;
+ client.add_account(&faucet_account, false).await?;
+
+ // Add keys to keystore
+ keystore.add_key(&alice_key_pair, alice_account.id()).await?;
+ keystore.add_key(&faucet_key_pair, faucet_account.id()).await?;
+
+ // The faucet mints; Alice later consumes. Fund both before either transaction.
+ fund_account(&mut client, faucet_account.id()).await?;
+ fund_account(&mut client, alice_account.id()).await?;
+
+ let amount: u64 = 1000;
+ // The faucet factory encodes callback support in the faucet account ID because
+ // transfer policies are configured above.
+ let fungible_asset = FungibleAsset::new(faucet_account.id(), amount)?;
+
+ // Mint the asset to Alice — this creates a P2ID note she can consume.
+ let transaction_request = TransactionRequestBuilder::new().build_mint_fungible_asset(
+ fungible_asset,
+ alice_account.id(),
+ NoteType::Public,
+ client.rng(),
+ )?;
+ client
+ .submit_new_transaction(faucet_account.id(), transaction_request)
+ .await?;
+ client.sync_state().await?;
+
+ // Public notes must be committed to a block before they can be consumed.
+ // Poll until the network includes our mint note in a block.
+ println!("Waiting for note to be consumable...");
+ loop {
+ client.sync_state().await?;
+
+ let consumable_notes = client
+ .get_consumable_notes(Some(alice_account.id()))
+ .await?;
+
+ if consumable_notes.is_empty() {
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ continue;
+ }
+
+ let notes: Vec = consumable_notes
+ .into_iter()
+ .map(|(record, _)| record.try_into().expect("Failed to convert to Note"))
+ .collect();
+
+ let consume_tx_request = TransactionRequestBuilder::new().build_consume_notes(notes)?;
+ client
+ .submit_new_transaction(alice_account.id(), consume_tx_request)
+ .await?;
+ client.sync_state().await?;
+
+ break;
+ }
+
+ //------------------------------------------------------------
+ // READ TOKEN BALANCE OF AN ACCOUNT
+ //------------------------------------------------------------
+
+ // Fetch the account again so the vault reflects the consumed note.
+ let alice_account: Account = client
+ .get_account(alice_account.id())
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("Account not found"))?
+ .try_into()?;
+
+ let asset_id = AssetId::new_fungible(faucet_account.id());
+ let balance = alice_account.vault().get_balance(asset_id)?;
+
+ println!("Alice's TEST token balance: {}", balance);
+
+ Ok(())
+}
+```
+
+```typescript title="src/demo.ts"
+import { MidenClient } from "@miden-sdk/miden-sdk";
+import { fundAccount } from "./funding";
+
+export async function demo() {
+ // Initialize client to connect with the Miden Testnet.
+ const client = await MidenClient.createTestnet();
+
+ // Create Alice's account and a faucet.
+ const alice = await client.accounts.create({
+ storage: "public",
+ });
+ console.log("Alice's account ID:", alice.id().toString());
+
+ const decimals = 8;
+ const maxSupply = 10_000_000n * 10n ** BigInt(decimals);
+ const faucet = await client.accounts.create({
+ type: 0, // Fungible faucet
+ symbol: "TEST",
+ decimals,
+ maxSupply,
+ storage: "public",
+ });
+ console.log("Faucet account ID:", faucet.id().toString());
+
+ // The faucet mints; Alice later consumes. Fund both before either transaction.
+ await fundAccount(client, faucet.id());
+ await fundAccount(client, alice.id());
+
+ // Mint 1000 tokens to Alice and consume the resulting P2ID note.
+ await client.transactions.mint({
+ account: faucet.id(),
+ to: alice.id(),
+ amount: 1000n,
+ type: "public",
+ waitForConfirmation: true,
+ });
+
+ console.log("Waiting for note to be consumable...");
+ const notes = await client.notes.listAvailable({ account: alice.id() });
+ await client.transactions.consume({
+ account: alice.id(),
+ notes: [notes[0]],
+ waitForConfirmation: true,
+ });
+
+ // Fetch Alice again so the vault reflects the consumed note.
+ const updatedAlice = await client.accounts.get(alice.id());
+ if (!updatedAlice) {
+ throw new Error("Alice's account was not found");
+ }
+
+ const balance = updatedAlice.vault().getBalance(faucet.id());
+ console.log("Alice's TEST token balance:", Number(balance));
+}
+```
+
+
+Expected output
+
+```text
+Alice's account ID: 0x5b2840a923dedc102ea67e0c1eba3c
+Faucet account ID: 0x29dd1dc628d2842032e751ed1b5da7
+Waiting for note to be consumable...
+Alice's TEST token balance: 1000
+```
+
+
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/setup/_category_.json b/versioned_docs/version-0.16/builder/get-started/setup/_category_.json
new file mode 100644
index 00000000..103ee735
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/setup/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Set Up",
+ "position": 1
+}
diff --git a/versioned_docs/version-0.16/builder/get-started/setup/cli-basics.md b/versioned_docs/version-0.16/builder/get-started/setup/cli-basics.md
new file mode 100644
index 00000000..b8ff9af6
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/setup/cli-basics.md
@@ -0,0 +1,220 @@
+---
+sidebar_position: 2
+title: CLI Basics
+description: Learn essential Miden CLI commands to create your wallet and mint your first tokens.
+---
+
+This guide covers essential Miden CLI commands for creating accounts, minting and managing tokens. Make sure to have [installed Miden development tools](./installation.md) using the `midenup` toolchain.
+
+## Create Your First Account
+
+### Generate a New Wallet
+
+Create a new Miden wallet account:
+
+```bash title=">_ Terminal"
+miden client sync
+miden client new-wallet
+```
+
+
+Expected output
+
+```text
+State synced to block
+...
+Generated and stored Falcon512 authentication key in keystore.
+Successfully created new wallet.
+To view account details execute miden client account -s 0x05bd1f642cd368800cc95956b2696a
+Setting account 0x05bd1f642cd368800cc95956b2696a as the default account ID.
+You can unset it with `miden client account --default none`.
+```
+
+
+
+The first command synchronizes the client with the latest network state. The second creates a basic wallet account with **private** storage locally. Its first successful transaction publishes the account onchain.
+
+### View Your Account
+
+List all your accounts:
+
+```bash title=">_ Terminal"
+miden client account
+```
+
+
+Expected output
+
+```text
+| Account ID | Kind | Type | Nonce | Status |
+|------------|------|------|-------|--------|
+| 0x970e3e4dbcd09b8035532edaa87bc9 | Regular | private | 0 | New |
+```
+
+
+
+View detailed information about your account:
+
+```bash title=">_ Terminal"
+miden client account -s
+```
+
+
+Expected output
+
+```text
+Account Information
+==================
+
+| Field | Value |
+|-------------------|--------------------------------------------------------------------------|
+| Address | mtst1qztsu0jdhngfhqp42vhd42rme9cqzkzy89e |
+| Account ID (hex) | 0x970e3e4dbcd09b8035532edaa87bc9 |
+| Account Commitment| 0x404a762b9a19e70bc8752381b17f909bc0bbab02c0b4636d8923d088ac8ebc04 |
+| Kind | Regular |
+| Type | private |
+| Code Commitment | 0x6a11161925930dae89cc24cbddf0d161cead39b0fe88c262d4e790cff35be01d |
+| Vault Root | 0x3e128c57f6cfa0d44ab1308994171af13cb513422add28d1916b3ff254fef82d |
+| Storage Root | 0x5f95d38174f10c8ce91a0202763b0813fdcbb2714704cda411af6483ebc8d012 |
+| Nonce | 0 |
+
+Assets:
+
+| Asset Type | Faucet | Amount |
+|------------|---------|---------|
+| | | |
+
+Storage:
+
+| Slot Name | Slot Type | Value/Commitment |
+|----------------------------------------------------------|-----------|--------------------------------------------------------------------|
+| miden::standards::auth::singlesig::scheme | Value | 0x0200000000000000000000000000000000000000000000000000000000000000 |
+| miden::standards::auth::singlesig::pub_key | Value | 0x113697002c3061328fce8c1e26dc433c536e967c8b91f30d81517e47f5980b3c |
+| miden::standards::inspection::storage_schema::commitment | Value | 0xb5724e35b8267d3be6bfc7d0ce50bfd6cce52de6da9f9e847ab24ac1bf7770f1 |
+```
+
+
+
+**Key Account Components:**
+
+- **Account ID**: Unique 120-bit identifier encoding the account visibility and version
+- **Vault**: Secure storage for your assets
+- **Storage**: Key-value store for account data (255 slots available)
+- **Code Commitment**: Hash of the account's smart contract logic
+- **Nonce**: Counter that increments with each state change
+
+## Account Management
+
+### Switch Between Accounts
+
+If you have multiple accounts, set which one to use as default:
+
+```bash title=">_ Terminal"
+miden client account --default
+```
+
+## Mint Your First Tokens
+
+Request test tokens for the wallet you just created. Replace `` with its account ID:
+
+```bash title=">_ Terminal"
+miden mint --target-account --amount 1000 --no-consume
+```
+
+The [testnet faucet](https://faucet.testnet.miden.io/) sends the tokens in a public note. The amount is in base units. `--no-consume` leaves the note for you to consume with `miden client` in the next step.
+
+After the faucet transaction is confirmed, sync and consume the note:
+
+```bash title=">_ Terminal"
+miden client sync
+miden client consume-notes --account
+```
+
+With no note IDs specified, `consume-notes` consumes the notes available to that account. Public funding notes are discovered by syncing; you do not need to import a file. If the note is not available yet, wait a few seconds and sync again.
+
+This first transaction publishes your wallet onchain and adds the tokens to its vault. Its protocol fee is paid from the native test tokens in the funding note, so the remaining balance is less than the requested amount.
+
+Once the transaction is confirmed, check your account and balance:
+
+```bash title=">_ Terminal"
+miden client sync
+miden client account -s
+```
+
+The account now has a nonzero nonce and a funded vault.
+
+
+Using a downloaded funding note
+
+If you request tokens through the web faucet and download a note file, import it before running the sync and consume commands above:
+
+```bash title=">_ Terminal"
+miden client import
+```
+
+
+
+## Create a New Project
+
+If you already created a project during [installation](./installation.md) (e.g., `my-test-project`), you can continue using it. Otherwise, create a new one:
+
+**Rust Workspace:**
+
+```bash title=">_ Terminal"
+miden new my-project
+```
+
+Creates a **Rust workspace** for developing, testing, and deploying Miden smart contracts using Rust.
+
+**Vite Frontend Project:**
+
+```bash title=">_ Terminal"
+# Using Yarn
+yarn create miden-app
+# Using NPM
+npx create-miden-app
+```
+
+Creates a minimal **Vite example project with Miden integration**, built on the standard Vite React TypeScript template.
+
+## Custom client configuration
+
+Initialize the client in your working directory when you want to test against a custom network endpoint or use different keys without touching your global config:
+
+```bash title=">_ Terminal"
+miden client init --local --network devnet
+```
+
+Available networks:
+
+- `testnet` - Miden's public test network
+- `devnet` - Development network
+- `localhost` - Local node for testing
+
+### Important Files Created
+
+When you initialize the Miden client with `--local`, a `.miden/` directory is created in your working directory with the following files:
+
+- **`.miden/miden-client.toml`**: Configuration file with network settings
+- **`.miden/store.sqlite3`**: Database storing your account data and transaction history
+- **`.miden/keystore/`**: Directory containing your private keys (keep secure!)
+- **`.miden/packages/`**: Pre-built account component packages
+
+:::danger
+Private keys in the `.miden/keystore/` directory are **not encrypted**. Keep these files secure and never share them.
+:::
+
+To use your global client configuration, run `miden client` commands from a
+directory that does not contain `./.miden/miden-client.toml`. Local lookup is
+limited to the current working directory, and the CLI falls back to the global
+configuration when that local file is absent. You do not need to delete the
+local directory to switch.
+
+`miden client clear-config` is destructive cleanup, not a configuration
+switch. It recursively removes the entire local `.miden/` directory, including
+the store and private keys; when no local `.miden/` directory exists, it falls
+back to removing the global directory. Its confirmation prompt does not
+enumerate that state. Use it only for confirmed disposable client state after
+preserving anything you need.
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/setup/installation.md b/versioned_docs/version-0.16/builder/get-started/setup/installation.md
new file mode 100644
index 00000000..e153951a
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/setup/installation.md
@@ -0,0 +1,257 @@
+---
+sidebar_position: 1
+title: Installation
+description: Get started with Miden development by installing Miden tools using the `midenup` toolchain.
+---
+
+This guide walks you through installing the Miden development tools using the `midenup` toolchain manager.
+
+## Prerequisites
+
+### Install Rust
+
+Miden development requires Rust. Install it using rustup:
+
+```bash title=">_ Terminal"
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+```
+
+Reload your PATH environment variable:
+
+```bash title=">_ Terminal"
+. "$HOME/.cargo/env"
+```
+
+Verify the installation:
+
+```bash title=">_ Terminal"
+rustc --version
+```
+
+
+Expected output
+
+```text
+rustc 1.98.1 (...) # or newer for client/protocol code
+```
+
+
+
+### Install Node.js & Yarn
+
+For TypeScript development with the Miden Web Client, you'll need Node.js and Yarn.
+
+**Install Node.js:**
+
+On macOS with Homebrew:
+
+```bash title=">_ macOS"
+brew install node
+```
+
+On Ubuntu/Debian:
+
+```bash title=">_ Ubuntu/Debian"
+curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
+sudo apt-get install -y nodejs
+```
+
+On Windows, use the Node.js installer from nodejs.org.
+
+**Install Yarn:**
+
+```bash title=">_ Terminal"
+# Install Yarn globally via npm
+npm install -g yarn
+```
+
+**Verify installations:**
+
+```bash title=">_ Terminal"
+node --version && yarn --version
+```
+
+
+Expected output
+
+```text
+v22.x.x # or higher
+1.22.x # or higher
+```
+
+
+
+### Install Miden CLI
+
+**Install midenup**
+
+The Miden toolchain installer makes it easy to manage Miden components:
+
+```bash title=">_ Terminal"
+cargo install midenup
+```
+
+This guide is verified with midenup **1.0.0**. To install that exact release, use `cargo install midenup --version 1.0.0`.
+
+:::info
+To install from source instead, name the package explicitly — the repository contains more than one binary: `cargo install --git https://github.com/0xMiden/midenup.git midenup`
+:::
+
+**Initialize midenup**
+
+```bash title=">_ Terminal"
+midenup init
+```
+
+This creates the `$MIDENUP_HOME` directory and sets up the `miden` command by creating a symlink in your Cargo bin directory (`$CARGO_HOME/bin/`, typically `~/.cargo/bin/`). Since Rust users already have this directory in their PATH, no additional PATH configuration is needed.
+
+Verify it works:
+
+```bash title=">_ Terminal"
+which miden
+```
+
+
+Expected output
+
+```text
+/Users//.cargo/bin/miden # macOS
+/home//.cargo/bin/miden # Linux
+```
+
+
+
+**Install Miden Toolchain**
+
+These v0.16 guides require a coherent v0.16 client, compiler, and protocol
+toolchain for the network you use. `midenup` resolves network names through the
+[published channel manifest](https://0xmiden.github.io/midenup/channel-manifest.json).
+
+:::warning Release prerequisite
+The stable v0.16 toolchain update is ready on midenup's `next` branch, but it
+has not yet been promoted to the published manifest. The published `testnet`
+channel still points to v0.15, while its explicit `0.16.0` channel is the older
+prerelease stack.
+
+Wait for the updated manifest to be published before using these
+network-dependent v0.16 guides against testnet.
+:::
+
+After the manifest meets that requirement, install the public testnet toolchain
+and make it the default:
+
+```bash title=">_ Terminal"
+midenup install testnet && midenup override testnet
+```
+
+:::note
+You may see `No artifact found. Proceeding to install from source` during installation. This is expected — it means pre-built binaries aren't available for your platform, so midenup compiles components from source. This can take 15-30 minutes.
+:::
+
+### Verify Installation
+
+Check that everything is working correctly:
+
+```bash title=">_ Terminal"
+midenup show active-toolchain
+miden client --help
+```
+
+
+Expected output
+
+```text
+testnet
+CLI actions
+Usage: miden client
+...
+```
+
+
+
+### Troubleshooting
+
+**"miden: command not found"**
+
+Ensure `$CARGO_HOME/bin` (typically `~/.cargo/bin/`) is in your PATH. This should already be configured if you installed Rust via rustup. Verify with:
+
+```bash title=">_ Terminal"
+echo $PATH | tr ':' '\n' | grep cargo
+```
+
+**"config error: missing field" when running `miden client` commands**
+
+If a previous `miden-client.toml` is incompatible with the current client, move
+only that file aside and re-initialize the same scope and network. The CLI loads
+`./.miden/miden-client.toml` from the current directory first, then falls back to
+the global configuration (`$MIDEN_CLIENT_HOME/miden-client.toml` when that
+variable is set, otherwise `~/.miden/miden-client.toml`).
+
+For example, to regenerate a local configuration without deleting its database
+or keys, replace `` with that configuration's network. Keep any existing
+backup rather than overwriting it:
+
+```bash title=">_ Terminal"
+mv -i .miden/miden-client.toml .miden/miden-client.toml.previous
+miden client init --local --network
+```
+
+For a global configuration, move the corresponding global
+`miden-client.toml` aside and omit `--local`. Review the regenerated store and
+keystore paths before making transactions, especially if the previous file used
+custom paths.
+
+Regenerating this file does not migrate an older database or make state from one
+network usable on another. Keep the old files until you have confirmed the
+release's storage compatibility and recovered the accounts you need.
+
+Do not use `miden client clear-config` for migration or configuration switching.
+It recursively removes the entire local `.miden/` directory, including the
+store and private keys; when no local `.miden/` directory exists, it falls back
+to removing the global directory. Its confirmation prompt does not enumerate
+that state. Use it only for confirmed disposable client state after preserving
+anything you need.
+
+## Set Up a Project
+
+The Quick Start guides let you follow along in either Rust or TypeScript. Scaffold whichever language you prefer — the two tabs in every later code example map 1:1 to the files below.
+
+### Rust Project
+
+```bash title=">_ Terminal"
+miden new my-test-project
+cd my-test-project
+```
+
+If successful, you'll see a new directory with Miden project files. The generated `rust-toolchain.toml` selects the Rust toolchain and components required by the project.
+
+For each Rust code example in the following pages, add a new binary under
+`integration/src/bin/` and run it with `cargo run --bin --release`.
+
+### TypeScript Project
+
+The TypeScript examples use the [`@miden-sdk/miden-sdk`](https://www.npmjs.com/package/@miden-sdk/miden-sdk) package and its `MidenClient` API. The SDK ships WebAssembly that runs in the browser, so the simplest runnable setup is a minimal Vite project:
+
+```bash title=">_ Terminal"
+npm create vite@latest miden-app -- --template vanilla-ts
+cd miden-app
+npm install @miden-sdk/miden-sdk@^0.16.0
+```
+
+Open `src/main.ts` and replace its contents with a simple entry point that calls your demo:
+
+```ts title="src/main.ts"
+import { demo } from "./demo";
+
+demo().catch(console.error);
+```
+
+For each TypeScript snippet in the following pages, save it as `src/demo.ts` (or another name imported from `main.ts`) and run:
+
+```bash title=">_ Terminal"
+npm run dev
+```
+
+The SDK initialises WebAssembly on first use; open the Vite dev server URL in your browser and watch the devtools console for output.
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/_category_.json b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/_category_.json
new file mode 100644
index 00000000..9041f571
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Your First Smart Contract",
+ "position": 5
+}
diff --git a/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/create.md b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/create.md
new file mode 100644
index 00000000..e94837d6
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/create.md
@@ -0,0 +1,339 @@
+---
+sidebar_position: 2
+title: Create Your Project
+description: Set up a new Miden project and understand the counter contract implementation.
+---
+
+In this section, you'll set up a new Miden project and understand the structure and implementation of both the counter account contract and increment note script.
+
+## Setting Up Your Project
+
+Create a new Miden project using the CLI:
+
+```bash title=">_ Terminal"
+miden new counter-project
+cd counter-project
+```
+
+This creates a workspace with the following structure:
+
+```text
+counter-project/
+├── contracts/ # Each contract as individual crate
+│ ├── counter-account/ # Example: Counter account contract
+│ │ ├── miden-project.toml # Miden package manifest
+│ │ └── src/lib.rs
+│ └── increment-note/ # Example: Increment note contract
+│ ├── miden-project.toml # Miden package manifest
+│ └── src/lib.rs
+├── integration/ # Integration crate (scripts + tests)
+│ ├── src/
+│ │ ├── bin/ # Rust binaries for onchain interactions
+│ │ ├── lib.rs
+│ │ └── helpers.rs # Temporary helper file
+│ └── tests/ # Test files
+├── Cargo.toml # Workspace root
+└── rust-toolchain.toml # Rust toolchain specification
+```
+
+The project follows Miden's design philosophy of clean separation:
+
+- **`contracts/`**: Your primary working directory for writing Miden smart contract code
+- **`integration/`**: All onchain interactions, deployment scripts, and tests
+
+Each contract is organized as its own individual crate, providing independent versioning, dependencies, and clear isolation between different contracts. Each contract crate also includes a `miden-project.toml` file next to `Cargo.toml`; the Miden compiler uses it to identify the project kind, WIT namespace, and compiled package dependencies.
+
+### Project Manifests
+
+The Miden-specific manifest is required for account components, notes, and transaction scripts. The counter account manifest identifies the exported account component interface:
+
+```toml title="contracts/counter-account/miden-project.toml"
+[package]
+name = "counter-account"
+version = "0.1.0"
+
+[lib]
+kind = "account-component"
+# Full `miden:/@` id. The interface segment is the
+# kebab-cased component trait name (`CounterContract` -> `counter-contract`).
+namespace = "miden:counter-account/counter-contract@0.1.0"
+path = "src/lib.rs"
+
+[dependencies]
+miden-core = "*"
+miden-protocol = "*"
+
+[package.metadata.miden]
+supported-types = ["RegularAccountImmutableCode"]
+```
+
+The increment note depends on the counter account package and the generated WIT
+that describes its callable interface:
+
+```toml title="contracts/increment-note/miden-project.toml"
+[package]
+name = "increment-note"
+version = "0.1.0"
+
+[lib]
+kind = "note"
+# Notes export a package-derived interface (`miden-`), matching the `#[note]` macro.
+namespace = "miden:increment-note/miden-increment-note@0.1.0"
+path = "src/lib.rs"
+
+[dependencies]
+miden-core = "*"
+miden-protocol = "*"
+counter-account = { path = "../counter-account" }
+
+```
+
+Build the contracts with `miden build` in dependency order, as shown below.
+Building the account first produces both its `.masp` package and the generated
+WIT consumed by the note's `#[account(...)]` wrapper. Plain `cargo check`,
+`cargo build`, and IDE analysis do not automatically build or stage those
+cross-component dependencies in the published SDK.
+
+## Building Your Contracts
+
+You can build individual contracts by navigating to their directory and running the Miden build command:
+
+```bash title=">_ Terminal"
+# Build the counter account contract
+cd contracts/counter-account
+miden build
+
+# Build the increment note contract
+cd ../increment-note
+miden build
+```
+
+This compiles the Rust contract code into a Miden package (`.masp` file), making it ready for deployment and interaction.
+
+## Understanding the Counter Account Contract
+
+Let's examine the counter account contract that comes with the project template. Open `contracts/counter-account/src/lib.rs`:
+
+```rust title="contracts/counter-account/src/lib.rs"
+// Do not link against libstd (i.e. anything defined in `std::`)
+#![no_std]
+#![feature(alloc_error_handler)]
+
+// However, we could still use some standard library types while
+// remaining no-std compatible, if we uncommented the following lines:
+//
+// extern crate alloc;
+
+use miden::{component, component_storage, felt, Felt, StorageMap, Word};
+
+/// Storage layout for the counter example.
+#[component_storage]
+struct CounterContractStorage {
+ /// Storage map holding the counter value.
+ #[storage(description = "counter contract storage map")]
+ count_map: StorageMap,
+}
+
+/// API of the counter contract account component.
+#[component]
+trait CounterContract {
+ /// Returns the current counter value stored in the contract's storage map.
+ #[account_procedure]
+ fn get_count(&self) -> Felt;
+ /// Increments the counter value stored in the contract's storage map by one.
+ #[account_procedure]
+ fn increment_count(&mut self) -> Felt;
+}
+
+#[component]
+impl CounterContract for CounterContractStorage {
+ fn get_count(&self) -> Felt {
+ // Define a fixed key for the counter value within the map
+ let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]);
+ // Read the value associated with the key from the storage map
+ self.count_map.get(key)
+ }
+
+ fn increment_count(&mut self) -> Felt {
+ // Define the same fixed key
+ let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]);
+ // Read the current value
+ let current_value: Felt = self.count_map.get(key);
+ // Increment the value by one
+ let new_value = current_value + felt!(1);
+ // Write the new value back to the storage map
+ self.count_map.set(key, new_value);
+ new_value
+ }
+}
+```
+
+### Counter Contract Walkthrough
+
+#### No-std Environment
+
+```rust
+#![no_std]
+```
+
+Miden contracts run in a `no_std` environment, meaning they don't link against Rust's standard library. This is essential for blockchain execution where contracts need to be deterministic and lightweight.
+
+#### Miden Library Imports
+
+```rust
+use miden::{component, component_storage, felt, Felt, StorageMap, Word};
+```
+
+These imports provide:
+
+- **`component`**: Macro for defining contract components
+- **`component_storage`**: Macro for defining a component's storage struct
+- **`felt`**: Macro for creating `Felt` literals (e.g., `felt!(1)`)
+- **`Felt`/`Word`**: Miden's native field element and word types
+- **`StorageMap`**: Key-value storage within account storage slots
+
+:::note[`felt` vs `Felt`]
+`Felt` is the field element type representing values in the Goldilocks prime field (p = 2^64 - 2^32 + 1). `felt!(1)` creates a `Felt` from an integer literal. A literal outside the field range causes a panic when evaluated. For runtime values, use the fallible `Felt::new(value)` and handle its `Result`.
+:::
+
+#### Contract Structure Definition
+
+```rust
+#[component_storage]
+struct CounterContractStorage {
+ /// Storage map holding the counter value.
+ #[storage(description = "counter contract storage map")]
+ count_map: StorageMap,
+}
+
+#[component]
+trait CounterContract {
+ /// Returns the current counter value stored in the contract's storage map.
+ #[account_procedure]
+ fn get_count(&self) -> Felt;
+ /// Increments the counter value stored in the contract's storage map by one.
+ #[account_procedure]
+ fn increment_count(&mut self) -> Felt;
+}
+```
+
+The `#[component_storage]` attribute marks the storage struct for this Miden [Account component](../../../reference/protocol/account/index.md), while the `#[component]` trait defines the component's interface. Every callable trait method must carry `#[account_procedure]`; an unmarked method is not exported. The `count_map` field is a `StorageMap` stored in a named storage slot of the account. Storage slots are identified by name rather than explicit index numbers — the slot name is derived automatically from the component's manifest namespace and field name (e.g., `counter_account::counter_contract::count_map`).
+
+**Important**: Miden account storage is organized into named slots. Each slot holds either a single typed value or a key-value map. Here, `StorageMap` provides typed access to a map-backed slot, converting its keys and values to and from `Word`. Each `Word` consists of four field elements (`Felt`), and each `Felt` belongs to the Goldilocks prime field and is represented using 64 bits.
+
+#### Contract Implementation
+
+```rust
+impl CounterContract for CounterContractStorage {
+ // Function implementations...
+}
+```
+
+The `CounterContract` trait defines the external interface that other contracts and notes can call. The `impl CounterContract for CounterContractStorage` block provides the behavior over the component's storage.
+
+#### Storage Key Strategy
+
+```rust
+let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]);
+```
+
+Both functions in the counter contract use the same fixed key `[0, 0, 0, 1]` to store and retrieve the counter value within the storage map. This demonstrates a simple but effective storage pattern.
+
+## Understanding the Increment Note Script
+
+Now let's examine the increment note script at `contracts/increment-note/src/lib.rs`:
+
+```rust title="contracts/increment-note/src/lib.rs"
+// Do not link against libstd (i.e. anything defined in `std::`)
+#![no_std]
+#![feature(alloc_error_handler)]
+
+// However, we could still use some standard library types while
+// remaining no-std compatible, if we uncommented the following lines:
+//
+// extern crate alloc;
+// use alloc::vec::Vec;
+
+use miden::*;
+
+/// Native account of the note: exposes the `counter-contract` component methods gathered from the `counter-contract` package.
+#[account(counter_account::CounterContract)]
+pub struct Wallet;
+
+#[note]
+struct IncrementNote;
+
+#[note]
+impl IncrementNote {
+ #[note_script]
+ fn run(self, _arg: Word, account: &mut Wallet) {
+ let initial_value = account.get_count();
+ account.increment_count();
+ let expected_value = initial_value + Felt::from_u32(1);
+ let final_value = account.get_count();
+ assert_eq(final_value, expected_value);
+ }
+}
+```
+
+### Increment Note Script Walkthrough
+
+#### No-std Setup
+
+Like the account contract, the note script uses `#![no_std]` and enables the `alloc_error_handler` language feature.
+
+#### Miden Imports
+
+```rust
+use miden::*;
+```
+
+The note script glob-imports the `miden` prelude: the `#[note]` and `#[account]` macros, the basic field/word types (`Felt`, `Word`), and free functions such as `assert_eq`. Listing the imports individually is easy to get wrong — `assert_eq` here is a function from the prelude, not Rust's `assert_eq!` macro, so omitting it fails to compile.
+
+#### Note Script Structure
+
+Note scripts use a struct-based pattern. The `#[note]` attribute on both the struct and `impl` block marks this as a Miden note script component:
+
+```rust
+#[note]
+struct IncrementNote;
+
+#[note]
+impl IncrementNote {
+ #[note_script]
+ fn run(self, _arg: Word, account: &mut Wallet) { ... }
+}
+```
+
+The struct definition (`IncrementNote`) provides a named type for the note script. Unlike account contracts, note scripts don't store persistent data — the struct serves as the entry point container. The `Wallet` type is declared with `#[account(counter_account::CounterContract)]`, which binds the note to the counter account interface generated from `miden-project.toml`.
+
+Learn more about [note scripts in the Miden documentation](../../../reference/protocol/note.md).
+
+#### The Note Script Function
+
+```rust
+#[note_script]
+fn run(self, _arg: Word, account: &mut Wallet) {
+ let initial_value = account.get_count();
+ account.increment_count();
+ let expected_value = initial_value + Felt::from_u32(1);
+ let final_value = account.get_count();
+ assert_eq(final_value, expected_value);
+}
+```
+
+The `#[note_script]` attribute marks this method as the entry point for note execution. The `self` parameter is required for methods in the `impl` block. The function:
+
+1. **Gets the initial counter value** using the bound counter account interface
+2. **Calls increment_count()** to increment the counter on the target account
+3. **Verifies the operation succeeded** by checking the final value matches expectations
+
+This demonstrates how note scripts interact with account contracts through their public interfaces, calling functions to change state.
+
+The counter example demonstrates a complete interaction pattern: the account contract manages persistent state, while the note script provides a mechanism to trigger state changes through note consumption.
+
+## Next Steps
+
+Now that you understand the contract code structure, let's move on to [deploying your contract](./deploy) and learn how the integration folder enables interaction with your contracts on the Miden network.
+
+---
diff --git a/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/deploy.md b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/deploy.md
new file mode 100644
index 00000000..69443976
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/deploy.md
@@ -0,0 +1,246 @@
+---
+sidebar_position: 3
+title: Deploy Your Contract
+description: Learn about the integration folder and deploy your counter contract to the Miden testnet.
+---
+
+# Deploy Your Contract
+
+In this section, you'll learn about how to deploy and interact with your counter contract using the included "increment-count" scripts.
+
+## Understanding the Integration Folder
+
+The `integration/` folder is a crucial part of your Miden project workspace. It serves as the command center for all interactions with your smart contracts. Let's explore its structure and purpose.
+
+From the workspace root, inspect your project's integration folder:
+
+```bash title=">_ Terminal"
+ls -la integration
+```
+
+You'll see a structure like:
+
+```text
+integration/
+├── Cargo.toml # Integration crate configuration
+├── src/
+│ ├── bin/ # Executable scripts for onchain interactions
+│ │ └── increment_count.rs # Script to deploy and increment counter
+│ ├── helpers.rs # Temporary helper file
+│ └── lib.rs # Exports helpers
+└── tests/ # Integration tests
+ └── counter_test.rs # Tests for counter contract
+```
+
+## Purpose of the Integration Folder
+
+The integration folder serves two essential functions in Miden development:
+
+### 1. Contract Interaction Scripts (Binary Executables)
+
+Think of the scripts in `src/bin/` as Miden's equivalent to [**Foundry scripts**](https://www.getfoundry.sh/forge/scripting). These are executable Rust binaries that handle all your contract interactions:
+
+- **Contract Deployment**: Scripts that create and deploy accounts to the network
+- **Function/Procedure Calls**: Scripts that interact with deployed contracts through notes or [transaction scripts](../../../reference/protocol/transaction.md#transaction-lifecycle)
+- **State Queries**: Scripts that read contract state from the network
+- **Operations**: Scripts for contract upgrades, configuration changes, etc.
+
+Each binary is designed to handle a specific task.
+
+### 2. Testing Infrastructure
+
+All testing logic for your smart contracts lives here:
+
+- **Integration Tests**: End-to-end tests that verify contract behavior on Testnet
+- **Mockchain Tests**: Local testing using Miden's testing framework
+
+This separation ensures your contract logic in `contracts/` remains clean and focused while all interaction complexity is managed in the integration layer.
+
+## The Increment Count Script
+
+Let's examine the `increment_count.rs` script located at `integration/src/bin/increment_count.rs`. This script demonstrates the complete lifecycle of deploying and interacting with your counter contract.
+
+The script performs these key operations:
+
+1. **Sets up a Miden client** connected to the testnet
+2. **Builds both contract packages** (counter account and increment note)
+3. **Creates the counter account** with initial storage configuration
+4. **Creates a sender account** for publishing notes
+5. **Creates and publishes the increment note**
+6. **Consumes the note** to trigger the counter increment
+
+### Running the Script
+
+From the workspace root, run the increment script:
+
+```bash title=">_ Terminal"
+cd integration
+cargo run --bin increment_count --release
+```
+
+
+Expected Output
+
+```text
+Latest block: 1238402
+Account ID: V1(AccountIdV1 { suffix: 6091438912547090176, prefix: 14314767458661568337 })
+Sender account ID: "0x7d9c7007a5773c116bc58f9f28762b"
+Counter note hash: "0xa3642c5eb08d8298a2fb8ed7f268f13c4139771c77540768a7347c882f47ab4f"
+Note publish transaction ID: "0xfc190c8edbf972115cd5f00b22c8eac06c9515d0403b84ff697efa7093d6b8a7"
+Consume transaction ID: "0xa1aa7971e146710df74a674b7e99d1968bec8d2db1c4da6077a7e22843c92045"
+```
+
+
+
+Congratulations, you have successfully deployed the Counter Contract to the Miden Testnet, and incremented its count by one! You can verify your transaction on [MidenScan](https://testnet.midenscan.com) by searching for your transaction ID.
+
+### What Happens During Execution
+
+The script demonstrates Miden's deployment flow:
+
+1. **Contract Building**: The script compiles both the counter account and increment note contracts
+2. **Account Creation**: Creates a counter account with initial storage (counter value = 0)
+3. **Note Publishing**: Creates an increment note and publishes it to the network
+4. **Note Consumption**: The counter account consumes the note, executing the increment logic
+5. **State Update**: The counter value increases and the change is recorded onchain
+
+This process shows how Miden contracts are deployed through state changes rather than separate deployment transactions.
+
+**Miden's Deployment Flow**: Creating an account locally does not deploy it. Its first committed state-changing transaction publishes it onchain. If the account first consumes a funding note, that transaction publishes it with the counter still at zero. Consuming the increment note then changes the count to one.
+
+## How the Scripts Work
+
+The integration scripts connect to the Miden client and compile each contract by invoking `miden build` as a separate process. After each build, the helper loads the generated Miden package into the native client. You don't need to build the contracts manually before running the script.
+
+Next, we look into how the scripts convert your Rust contract code into deployable Miden contracts.
+
+## Script Breakdown
+
+Let's examine key parts of the increment script:
+
+### Client Setup
+
+```rust
+let ClientSetup { mut client, keystore } = setup_client().await?;
+let sync_summary = client.sync_state().await?;
+```
+
+This establishes a connection to the Miden testnet and synchronizes with the latest network state.
+
+### Building Contracts from Source
+
+The first step is building the Rust contracts into Miden packages:
+
+```rust
+// Build the counter account contract from source
+let counter_package = Arc::new(
+ build_project_in_dir(Path::new("../contracts/counter-account"), true)
+ .context("Failed to build counter account contract")?
+);
+
+// Build the increment note script from source
+let note_package = Arc::new(
+ build_project_in_dir(Path::new("../contracts/increment-note"), true)
+ .context("Failed to build increment note contract")?
+);
+```
+
+The `build_project_in_dir()` function:
+
+- Takes the path to a contract project
+- Invokes `miden build` in a separate process, using `--release` when requested
+- Resolves the generated `.masp` artifact under the project's `target/miden/` directory
+- Reads and deserializes the compiled package for the native client
+
+These packages contain all the information needed to deploy and interact with your contracts on the Miden network.
+
+### Converting Packages to Deployable Accounts
+
+Once we have the compiled packages, we convert them into deployable accounts and notes:
+
+```rust
+// Configure initial storage for the counter account.
+let counter_storage_slot = counter_storage_slot()?;
+let mut init_storage_data = InitStorageData::default();
+init_storage_data
+ .insert_map_entry(counter_storage_slot, COUNTER_STORAGE_KEY, 0_u64)
+ .context("Failed to seed counter storage")?;
+let counter_cfg = AccountCreationConfig {
+ init_storage_data,
+ ..Default::default()
+};
+
+// Convert the counter package into a deployable account
+let counter_account = create_account_from_package(
+ &mut client,
+ counter_package.clone(),
+ counter_cfg
+)
+.await
+.context("Failed to create counter account")?;
+```
+
+The `create_account_from_package()` function:
+
+- Takes the compiled contract package
+- Combines it with the provided configuration (storage, settings, etc.)
+- Creates a deployable Miden account that can be used in transactions
+
+**Important**: Accounts that use storage must have that storage seeded when instantiating the account. Storage slots are identified by name rather than index. The slot name follows the pattern `::::`, derived from the component's manifest namespace. We seed the storage with:
+
+- A named `StorageMap` slot, returned by the `counter_storage_slot()` helper (`counter_account::counter_contract::count_map`)
+- The counter key `COUNTER_STORAGE_KEY` (`[0, 0, 0, 1]`), mapped to the initial count `0`
+
+`InitStorageData` carries these seed values into `AccountComponent::from_package()`, which the helper calls for you.
+
+This pre-initialization ensures the account's storage is properly configured before deployment.
+
+### Converting Packages to Executable Notes
+
+Similarly, we convert the note package into an executable note:
+
+```rust
+// Build the increment note directly from the compiled package.
+let counter_note = NoteBuilder::new(sender_account.id(), client.rng())
+ .package((*note_package).clone())
+ .tag(0)
+ .build()
+ .context("Failed to create counter note from package")?;
+
+// Publish the note to the network
+let note_publish_request = TransactionRequestBuilder::new()
+ .own_output_notes(vec![counter_note.clone()])
+ .build()
+ .context("Failed to build note publish transaction request")?;
+```
+
+`NoteBuilder` (from `miden_standards::testing::note`):
+
+- Takes the sender account ID and the client's RNG
+- Accepts the compiled note script package via `.package()`
+- Produces an executable note containing the increment script logic
+- The note can then be published to the network and consumed by the target (counter) account
+
+This demonstrates the complete workflow: Rust source code → compiled packages → deployable accounts/notes → network transactions.
+
+### Note Consumption
+
+```rust
+let consume_note_request = TransactionRequestBuilder::new()
+ .input_notes([(counter_note.clone(), None)])
+ .build()
+ .context("Failed to build consume note transaction request")?;
+
+let consume_tx_id = client
+ .submit_new_transaction(counter_account.id(), consume_note_request)
+ .await
+ .context("Failed to create consume note transaction")?;
+```
+
+The counter account consumes the increment note, executing the note script which calls the counter's increment function.
+
+## Next Steps
+
+Congratulations! You've successfully deployed and interacted with your first Miden smart contract. The integration folder provides the foundation for managing all aspects of your contract lifecycle.
+
+This completes the core smart contract development workflow on Miden. You're now equipped to build and deploy your own smart contracts using these patterns and tools!
diff --git a/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/index.md b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/index.md
new file mode 100644
index 00000000..826cec03
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/index.md
@@ -0,0 +1,71 @@
+---
+sidebar_position: 1
+title: Your First Smart Contract
+description: Learn to build, test, and deploy smart contracts on Miden using Rust.
+---
+
+# Your First Smart Contract
+
+Welcome to the **Your First Smart Contract** guide! This tutorial will walk you through building, testing, and deploying your first Miden smart contract using Rust.
+
+## What You'll Learn
+
+By the end of this tutorial, you will have:
+
+- **Set up a Miden project** with the proper workspace structure
+- **Written and understood** a counter smart contract and increment note
+- **Deployed your contract** to the Miden testnet using integration scripts
+- **Mastered the fundamentals** of Miden's account-based smart contract model
+
+This guide focuses on practical, hands-on learning. You'll work with real code and deploy to a live network, giving you everything needed to start building on Miden.
+
+## What We'll Build
+
+You'll create a **counter contract system** consisting of:
+
+- **Counter Account**: A smart contract that stores and manages a counter value in its storage
+- **Increment Note**: A note script that increments the counter when executed
+- **Integration Scripts**: Deployment and interaction scripts for managing the contract lifecycle
+
+The counter example is designed to teach core Miden concepts through a simple, understandable use case that you can extend for your own projects.
+
+## Prerequisites
+
+Before starting this guide, ensure you have completed the [Installation](../setup/installation) tutorial and have:
+
+- **Rust toolchain** installed and configured
+- **midenup toolchain** installed with Miden CLI tools
+
+:::tip Prerequisites Required
+You need those development tools installed for this guide. If you haven't set up your environment yet, please complete the [installation](../setup/installation) guide first.
+:::
+
+## No Prior Experience Required
+
+This tutorial is designed for developers new to Miden. You don't need prior experience with:
+
+- Miden's account model or note-based transactions
+- Smart contract development on Miden
+- The specifics of Rust development for blockchain
+
+We'll explain these concepts as we encounter them in the tutorial.
+
+## Getting Help
+
+If you get stuck during this tutorial:
+
+- Check the rest of the Miden Docs for detailed technical references
+- Join the [Build On Miden](https://t.me/BuildOnMiden) Telegram community for support
+- Review the code examples in your project's `contracts` and `integration/` folder
+
+## Guide Structure
+
+This tutorial is divided into focused sections:
+
+1. **[Create Your Project](./create)** - Set up your workspace and understand the counter contract code
+2. **[Deploy Your Contract](./deploy)** - Learn the integration folder and deploy to testnet
+3. **[Test Your Contract](./test)** - Learn how to effectively test your contracts
+
+Each section builds on the previous one, so we recommend following them in order.
+
+Ready to build your first Miden smart contract? Let's get started!
diff --git a/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/test.md b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/test.md
new file mode 100644
index 00000000..62f386f3
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/get-started/your-first-smart-contract/test.md
@@ -0,0 +1,314 @@
+---
+sidebar_position: 4
+title: Test Your Contract
+description: Learn how to write and run tests for your Miden smart contracts using the integration testing framework.
+---
+
+# Test Your Contract
+
+In this final section, you'll learn how to test your counter contract using Miden's **Mockchain** - a purpose-built testing framework that enables fast, local testing without network dependencies.
+
+## Test Structure and Organization
+
+All tests for your smart contracts should be placed in the `integration/tests/` folder. This follows the same separation of concerns we've seen throughout the project:
+
+- **`contracts/`**: Contains your contract source code
+- **`integration/src/bin/`**: Contains deployment and interaction scripts
+- **`integration/tests/`**: Contains all test files for your contracts
+
+This structure keeps your contract logic clean while providing a dedicated space for comprehensive testing.
+
+## Local Testing with Mockchain
+
+For most testing scenarios, we use Miden's **Mockchain** - a local, mocked blockchain instance specifically designed for testing. While you can also create tests that use the Miden client for end-to-end testing and onchain interactions, the Mockchain provides the best developer experience for unit and integration testing.
+
+### What is the Mockchain?
+
+The Mockchain is Miden's purpose-built testing framework that provides several key advantages over testing against a live network:
+
+- **Blazing Fast Tests**: Run tests locally without network latency or external dependencies
+- **Full State Control**: Manipulate blockchain state precisely to create specific test scenarios
+- **Simpler Code**: Cleaner, more focused test logic without network complexity
+- **Deterministic Results**: Consistent test outcomes independent of network conditions
+- **Debugging Capabilities**: Detailed inspection of transaction execution and state changes
+
+This makes testing faster, more reliable, and easier to debug than testing against the testnet.
+
+## Running the Tests
+
+Execute your tests from the integration directory using the standard Cargo test command:
+
+```bash title="Terminal"
+cd integration
+cargo test --release
+```
+
+You should see output confirming the test passes:
+
+```text title="Expected Output"
+running 1 test
+test counter_test ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
+```
+
+## Understanding the Mockchain Test
+
+Your project includes a comprehensive test file at `integration/tests/counter_test.rs` that demonstrates how to test the counter contract using the Mockchain. Let's walk through this test to understand the testing patterns:
+
+
+Test File
+
+```rust title="integration/tests/counter_test.rs"
+use std::{path::Path, sync::Arc};
+
+use anyhow::Context;
+use integration::helpers::{build_project_in_dir, counter_storage_slot, COUNTER_STORAGE_KEY};
+use miden_client::{
+ account::{
+ component::InitStorageData, AccountBuilder, AccountComponent, AccountType, StorageMapKey,
+ },
+ auth::AuthSchemeId,
+ crypto::RandomCoin,
+ note::NoteScript,
+ transaction::RawOutputNote,
+ Word,
+};
+use miden_standards::testing::note::NoteBuilder;
+use miden_testing::{AccountState, Auth, MockChain};
+
+#[tokio::test]
+async fn counter_test() -> anyhow::Result<()> {
+ // Test that after executing the increment note, the counter value is incremented by 1
+ let mut builder = MockChain::builder();
+
+ // Create note sender account
+ let sender = builder.add_existing_wallet(Auth::BasicAuth {
+ auth_scheme: AuthSchemeId::Falcon512Poseidon2,
+ })?;
+
+ // Build contracts
+ let contract_package = Arc::new(build_project_in_dir(
+ Path::new("../contracts/counter-account"),
+ true,
+ )?);
+ let note_package = Arc::new(build_project_in_dir(
+ Path::new("../contracts/increment-note"),
+ true,
+ )?);
+
+ // Create the counter account with its initial storage through the component schema.
+ let counter_storage_slot = counter_storage_slot()?;
+ let mut init_storage_data = InitStorageData::default();
+ init_storage_data.insert_map_entry(counter_storage_slot.clone(), COUNTER_STORAGE_KEY, 0_u64)?;
+
+ let counter_component = AccountComponent::from_package(&contract_package, &init_storage_data)
+ .context("failed to build account component from counter package")?;
+ let counter_account = builder.add_account_from_builder(
+ Auth::BasicAuth {
+ auth_scheme: AuthSchemeId::Falcon512Poseidon2,
+ },
+ AccountBuilder::new([3_u8; 32])
+ .account_type(AccountType::Public)
+ .with_component(counter_component),
+ AccountState::Exists,
+ )?;
+
+ let mut note_rng = RandomCoin::new(Word::from(
+ NoteScript::from_package(note_package.as_ref())
+ .context("failed to build note script from package")?
+ .root(),
+ ));
+ let counter_note = NoteBuilder::new(sender.id(), &mut note_rng)
+ .package((*note_package).clone())
+ .build()
+ .context("failed to build counter note from package")?;
+
+ // add counter account and note to mockchain
+ builder.add_output_note(RawOutputNote::Full(counter_note.clone()));
+
+ // Build the mock chain
+ let mut mock_chain = builder.build()?;
+
+ // Build the transaction from the committed account and input note
+ let transaction = mock_chain
+ .build_transaction(counter_account.id())
+ .authenticated_input_note(counter_note.id())
+ .build()?;
+
+ // Execute the transaction
+ let executed_transaction = transaction.execute().await?;
+
+ // Add the executed transaction to the mockchain
+ mock_chain.add_pending_executed_transaction(&executed_transaction)?;
+ mock_chain.prove_next_block()?;
+
+ // Get the count from the updated counter account
+ let count = mock_chain
+ .committed_account(counter_account.id())?
+ .storage()
+ .get_map_item(
+ &counter_storage_slot,
+ StorageMapKey::new(COUNTER_STORAGE_KEY),
+ )
+ .expect("Failed to get counter value from storage slot");
+
+ assert_eq!(
+ count[0].as_canonical_u64(),
+ 1,
+ "Count value is not equal to 1"
+ );
+ Ok(())
+}
+```
+
+
+
+## Test Code Walkthrough
+
+Let's break down this test step by step to understand how Mockchain testing works.
+
+### 1. Setting Up the Mockchain Builder
+
+```rust
+let mut builder = MockChain::builder();
+let sender = builder.add_existing_wallet(Auth::BasicAuth {
+ auth_scheme: AuthSchemeId::Falcon512Poseidon2,
+})?;
+```
+
+**What's happening:**
+
+- We instantiate the **Mockchain builder**, which is used to configure our testing environment
+- We create a **sender account** using basic authentication - this account will publish the increment note
+- The builder pattern allows us to incrementally add all the components needed for our test
+
+### 2. Building the Contract Packages
+
+```rust
+let contract_package = Arc::new(build_project_in_dir(
+ Path::new("../contracts/counter-account"),
+ true,
+)?);
+let note_package = Arc::new(build_project_in_dir(
+ Path::new("../contracts/increment-note"),
+ true,
+)?);
+```
+
+**What's happening:**
+
+- Just like in the deployment script, we **build both contract packages** (counter account and increment note)
+- The `build_project_in_dir()` function compiles the Rust contracts into Miden packages
+- We wrap them in `Arc` for efficient memory sharing across the test
+
+### 3. Creating the Test Account and Note
+
+```rust
+// Create the counter account with its initial storage through the component schema.
+let counter_storage_slot = counter_storage_slot()?;
+let mut init_storage_data = InitStorageData::default();
+init_storage_data.insert_map_entry(counter_storage_slot.clone(), COUNTER_STORAGE_KEY, 0_u64)?;
+
+let counter_component = AccountComponent::from_package(&contract_package, &init_storage_data)
+ .context("failed to build account component from counter package")?;
+let counter_account = builder.add_account_from_builder(
+ Auth::BasicAuth {
+ auth_scheme: AuthSchemeId::Falcon512Poseidon2,
+ },
+ AccountBuilder::new([3_u8; 32])
+ .account_type(AccountType::Public)
+ .with_component(counter_component),
+ AccountState::Exists,
+)?;
+
+let mut note_rng = RandomCoin::new(Word::from(
+ NoteScript::from_package(note_package.as_ref())
+ .context("failed to build note script from package")?
+ .root(),
+));
+let counter_note = NoteBuilder::new(sender.id(), &mut note_rng)
+ .package((*note_package).clone())
+ .build()
+ .context("failed to build counter note from package")?;
+```
+
+**What's happening:**
+
+- We seed the **counter account's initial storage** through `InitStorageData`, mapping `COUNTER_STORAGE_KEY` to `0` inside the named slot returned by `counter_storage_slot()`
+- `AccountComponent::from_package()` turns the compiled package plus that storage seed into an account component
+- `builder.add_account_from_builder()` registers the account with the mockchain in the `AccountState::Exists` state, so it behaves like an already-deployed account
+- The note is built with `NoteBuilder`, seeded from a `RandomCoin` derived from the note script's MAST root — this keeps note generation deterministic across test runs
+
+### 4. Adding the Note to the Mockchain
+
+```rust
+builder.add_output_note(RawOutputNote::Full(counter_note.clone()));
+let mut mock_chain = builder.build()?;
+```
+
+**What's happening:**
+
+- We **add the increment note** as a full output note to the mockchain
+- We **build the mockchain** - now we have a complete testing environment ready to use
+
+The counter account does not need a separate `add_account()` call: `add_account_from_builder()` already registered it in the previous step.
+
+### 5. Creating and Executing the Transaction
+
+```rust
+let transaction = mock_chain
+ .build_transaction(counter_account.id())
+ .authenticated_input_note(counter_note.id())
+ .build()?;
+
+let executed_transaction = transaction.execute().await?;
+```
+
+**What's happening:**
+
+- We **build the transaction** against the committed counter account and add the counter note as an authenticated input
+- We **execute the transaction** - this runs the increment logic locally in the mockchain
+
+### 6. Verifying the Results
+
+```rust
+// Add the executed transaction to the mockchain
+mock_chain.add_pending_executed_transaction(&executed_transaction)?;
+mock_chain.prove_next_block()?;
+
+// Get the count from the updated counter account
+let count = mock_chain
+ .committed_account(counter_account.id())?
+ .storage()
+ .get_map_item(
+ &counter_storage_slot,
+ StorageMapKey::new(COUNTER_STORAGE_KEY),
+ )
+ .expect("Failed to get counter value from storage slot");
+
+assert_eq!(
+ count[0].as_canonical_u64(),
+ 1,
+ "Count value is not equal to 1"
+);
+```
+
+**What's happening:**
+
+- We **add the executed transaction** to the mockchain and prove the next block, which commits the new account state
+- We **read the counter value** back from `mock_chain.committed_account()` — the committed state already reflects the transaction, so there is no need to apply the account delta by hand
+- We **assert that the count equals 1** - verifying the increment operation worked correctly
+
+The test verifies the complete flow: the increment note successfully increments the counter from 0 to 1, proving our smart contract works as expected.
+
+## Next Steps
+
+Congratulations! You've successfully completed the Miden smart contract quick start guide. You're now equipped to build more sophisticated smart contracts on Miden. Consider exploring:
+
+To deepen your knowledge, we recommend exploring the following resources:
+
+- Visit the [Tutorials section](../../tutorials/) for detailed, hands-on guides on topics such as contract interactions, advanced storage, custom note scripting, and integrating with external applications.
+- For in-depth technical explanations, consult the [Reference section](../../../reference/) of the documentation. Here you'll find comprehensive information on Miden's architecture, account model, transaction lifecycle, and the underlying zero-knowledge technology that powers the network.
+
+The foundational patterns and concepts you've practiced in this Quick Start will enable you to build complex, privacy-preserving applications on the Miden network. Continue with the resources above to take your development further!
diff --git a/versioned_docs/version-0.16/builder/glossary.md b/versioned_docs/version-0.16/builder/glossary.md
new file mode 100644
index 00000000..dc20cb0b
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/glossary.md
@@ -0,0 +1,144 @@
+---
+title: Glossary
+description: "Key terms and definitions used throughout the Miden docs — grouped by area (accounts, notes, protocol, Guardian, cryptography)."
+pagination_next: null
+---
+
+# Glossary
+
+Key terms and definitions used throughout the Miden docs. Grouped thematically — if you're not sure where something lives, use your browser's find (⌘F / Ctrl+F).
+
+
+
+ Account, AccountCode, AccountComponent, AccountId, AccountStorage, MultiSig, AccountBuilder.
+
+
+ Note, Note script, Note tag, Note ID, Nullifier, Asset, AssetVault.
+
+
+ Block, Batch, Kernel, Prover, Miden Assembly, Felt, Word.
+
+
+ Miden Guardian, Canonicalization, Delta, Delta Proposal, Threshold Signature.
+
+
+
+## Accounts
+
+### Account
+
+An account is a data structure that represents an entity (user account, smart contract) on the Miden blockchain — analogous to smart contracts.
+
+### Account builder
+
+Account builder provides a structured way to create and initialize new accounts on the Miden network with specific properties, permissions, and initial state.
+
+### AccountCode
+
+The executable code associated with an account.
+
+### AccountComponent
+
+A modular unit of code representing a piece of an account's functionality. Each `AccountCode` is composed of multiple `AccountComponent`s.
+
+### AccountId
+
+A value that uniquely identifies each account on Miden.
+
+### AccountIdVersion
+
+Represents the different versions of account identifier formats supported by Miden.
+
+### AccountStorage
+
+A key-value store associated with an account. Made up of storage slots.
+
+### MultiSig
+
+A multi-signature account on Miden that requires a configurable threshold (N-of-M) of authorized signers to approve transactions before execution. MultiSig workflows are coordinated through [Miden Guardian](./miden-guardian/).
+
+## Notes & assets
+
+### Note
+
+A fundamental data structure that represents an offchain asset or a piece of information that can be transferred between accounts. Miden's UTXO-like model is designed around notes. **Output notes** are new notes created by a transaction; **input notes** are those consumed (spent) by a transaction.
+
+### Note script
+
+A program that defines the rules and conditions under which a note can be consumed.
+
+### Note tag
+
+An identifier or metadata associated with notes that provides additional filtering capabilities.
+
+### Note ID
+
+A unique identifier assigned to each note to distinguish it from other notes.
+
+### Nullifier
+
+A cryptographic commitment that marks a note as spent, preventing it from being consumed again.
+
+### Asset
+
+A digital resource with value that can be owned, transferred, and managed within the Miden blockchain.
+
+### AssetVault
+
+The container used for managing assets within accounts. Provides a way to store and transfer assets associated with each account.
+
+## Protocol & VM
+
+### Block
+
+A fundamental data structure that groups multiple batches together and forms the blockchain's state.
+
+### Batch
+
+A collection of transactions grouped together, to be aggregated into blocks — improves network throughput.
+
+### Kernel
+
+A fundamental module of the Miden VM that acts as a base layer, providing core functionality and security guarantees for the protocol.
+
+### Prover
+
+Responsible for generating zero-knowledge proofs that attest to the correctness of program execution without revealing the underlying data.
+
+### Miden Operator
+
+An entity that runs the node infrastructure powering the Miden network. Operators verify transaction proofs, record created notes and consumed-note nullifiers in the state databases, build the batches and blocks that progress the chain, and execute network transactions on behalf of users (for example, consuming a note against a public DEX contract).
+
+### Miden Assembly
+
+An assembly language specifically designed for the Miden VM — a low-level language with specialized instructions optimized for zero-knowledge proof generation.
+
+### Felt
+
+A Felt (Field Element) is the primitive cryptographic data type used by the Miden VM. It represents an element in the finite (Goldilocks) field: `p = 2^64 − 2^32 + 1`.
+
+### Word
+
+A data structure that represents the basic unit of computation and storage in Miden. Composed of four `Felt`s.
+
+## Guardian & multisig
+
+### Miden Guardian
+
+Infrastructure built by OpenZeppelin for managing private account state on Miden. Guardian provides a server and client SDKs for backing up, syncing, and coordinating state across devices and parties without trust assumptions. See the [Miden Guardian documentation](./miden-guardian/).
+
+### Canonicalization
+
+The background process by which [Miden Guardian](./miden-guardian/) promotes candidate deltas to canonical status by verifying them against the Miden network.
+
+### Delta
+
+A Delta represents the changes between two states `s` and `s'`. Applying a Delta `d` to `s` produces `s'`.
+
+### Delta Proposal
+
+A coordination mechanism in [Miden Guardian](./miden-guardian/) that allows multiple signers to propose, review, and co-sign state changes before they are promoted to a canonical delta.
+
+### Threshold Signature
+
+A cryptographic scheme where a minimum number of signers (the threshold) out of a total group must sign for a transaction to be valid. Used in Miden's MultiSig accounts.
diff --git a/versioned_docs/version-0.16/builder/index.md b/versioned_docs/version-0.16/builder/index.md
new file mode 100644
index 00000000..e2a97710
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/index.md
@@ -0,0 +1,89 @@
+---
+sidebar_label: Introduction
+sidebar_position: 0
+pagination_next: null
+---
+
+# Build on Miden
+
+Miden is a zero-knowledge layer 2 where every account is an independent state machine and state is private by default. You author accounts, notes, and transactions in Rust, compile them to Miden Assembly (MASM), and prove them client-side — the network verifies each proof without ever seeing your private state.
+
+This is the developer's entry point. **New to Miden?** Start with [Get started](./get-started) to install the toolchain and send your first transaction, then build [Your first smart contract](./get-started/your-first-smart-contract), and dip into the [Reference](../reference) when you want to know how it all works underneath. Already oriented? Jump straight to any section below.
+
+## Start here
+
+
+
+ Install midenup, create a wallet, and send your first transaction — in under ten minutes.
+
+
+ Walk through writing, proving, and deploying a counter contract in Rust.
+
+
+
+
+Building against the public testnet? Grab free test assets from the [faucet](https://faucet.testnet.miden.io/), and find every public endpoint — RPC, block explorer, status — on the [Network](./tools/network) page.
+
+
+## Build
+
+
+
+ Accounts, notes, storage, components, transactions — the full Rust SDK surface.
+
+
+ Real-world examples: the Miden Bank, private multisig, custom note scripts.
+
+
+ Testing, debugging, and common pitfalls when writing Miden programs.
+
+
+ Rust, Web, and React SDKs · playground · block explorer · CLI.
+
+
+
+## Ship
+
+
+
+ Breaking changes, renames, and new features across accounts, notes, transactions, MASM, and the client.
+
+
+ Backup, sync, and coordinate private account state across devices.
+
+
+ Multi-party threshold signature workflows powered by Guardian.
+
+
+
+## Reference
+
+
+
+ Frequently asked questions about Miden.
+
+
+ Key terms and definitions used throughout the docs.
+
+
+
+## Community
+
+- [Telegram](https://t.me/BuildOnMiden) — technical discussion
+- [GitHub](https://github.com/0xMiden) — source code
+- [Roadmap](https://miden.xyz/roadmap) — what's coming next
+
+import SectionLinks from '@site/src/components/SectionLinks';
+
+
+
+---
+
+Licensed under the [MIT License](http://opensource.org/licenses/MIT).
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/architecture/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/architecture/_category_.json
new file mode 100644
index 00000000..b7a3ae9d
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/architecture/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Architecture",
+ "position": 2
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/architecture/infra.md b/versioned_docs/version-0.16/builder/miden-guardian/architecture/infra.md
new file mode 100644
index 00000000..cd357a3e
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/architecture/infra.md
@@ -0,0 +1,296 @@
+---
+title: "Guardian AWS Deployment Architecture"
+sidebar_position: 2
+---
+This document explains how the Guardian server is deployed on AWS and how the
+resources in [`infra/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/) fit together. It is a map for engineers
+who already know the application and want to understand the runtime topology,
+or for operators who need to know which Terraform file owns which AWS resource.
+
+Companion docs:
+- [`infra/README.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/README.md) — Terraform variables reference and
+ raw `terraform apply` workflow.
+- [`docs/SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md) — end-to-end deploy
+ guide via `scripts/aws-deploy.sh`.
+- [`docs/architecture/services.md`](./services.md) — logical service
+ decomposition inside the server process.
+
+## TL;DR
+
+The stack is a single Fargate service behind an Application Load Balancer,
+backed by RDS PostgreSQL, with secrets in AWS Secrets Manager and DNS managed
+through Route 53 and/or Cloudflare. The same Terraform configuration deploys
+both `dev` and `prod` profiles — `prod` adds autoscaling, RDS Proxy, and
+storage autoscaling on top of the same base topology.
+
+## Topology
+
+```mermaid
+flowchart LR
+ Client["Client (SDK / curl / grpcurl)"]
+ DNS["Route 53 / Cloudflare guardian.openzeppelin.com"]
+ ACM["ACM certificate"]
+
+ subgraph VPC["AWS VPC"]
+ direction LR
+
+ subgraph PublicSubnets["Public subnets (≥2 AZs)"]
+ ALB["Application Load Balancer :80 → redirect :443 HTTPS → server:3000 :443 /guardian.Guardian/* → server:50051"]
+ end
+
+ subgraph TaskSubnets["ECS task subnets"]
+ Fargate["ECS Fargate Service guardian server :3000 HTTP · :50051 gRPC"]
+ end
+
+ subgraph DataSubnets["DB subnets (≥2 AZs)"]
+ Proxy["RDS Proxy (prod only)"]
+ RDS[("RDS PostgreSQL")]
+ end
+
+ Fargate -- "TLS, prod" --> Proxy
+ Proxy --> RDS
+ Fargate -- "dev: direct" --> RDS
+ end
+
+ subgraph AWSServices["AWS managed services"]
+ SM["Secrets Manager DATABASE_URL · ack keys · operator pubkeys · EVM config"]
+ CW["CloudWatch Logs"]
+ ECR["ECR guardian-server image"]
+ IAM["IAM task / execution / proxy roles"]
+ end
+
+ Client --> DNS --> ALB
+ ACM -. cert .-> ALB
+ Fargate -. pull image .-> ECR
+ Fargate -- "stdout / stderr" --> CW
+ Fargate -- "GetSecretValue" --> SM
+ Proxy -- "GetSecretValue (creds)" --> SM
+ IAM -. assume role .-> Fargate
+ IAM -. assume role .-> Proxy
+```
+
+### Why this shape
+
+- **One service, one image.** Guardian is a single Rust binary that exposes
+ HTTP and gRPC from the same process. There is no API gateway, no sidecar.
+ The ALB does layer-7 routing on path so HTTPS clients and gRPC clients
+ share port `443` on the public hostname.
+- **State lives in Postgres.** Authentication, account state, deltas,
+ proposals, audit logs all persist in RDS. The server itself is stateless;
+ scaling is "add more tasks".
+- **Identity lives in Secrets Manager.** ACK signing keys (Falcon + ECDSA)
+ used to authenticate Guardian's responses are stored in Secrets Manager in
+ `prod` and bootstrapped into the container's filesystem keystore at
+ startup. In `dev` the server auto-generates ephemeral keys.
+- **Stage profile toggles capacity, not topology.** `dev` and `prod` deploy
+ the same set of resource types; `prod` flips autoscaling on, sizes RDS up,
+ and inserts RDS Proxy between ECS and RDS. There is no separate `prod`
+ Terraform module.
+
+## Request flow
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant DNS as Route53/Cloudflare
+ participant ALB as ALB (:443)
+ participant S as Server task (Fargate)
+ participant P as RDS Proxy
+ participant DB as RDS Postgres
+ participant SM as Secrets Manager
+
+ C->>DNS: resolve guardian.openzeppelin.com
+ DNS-->>C: ALB DNS / Cloudflare IP
+ C->>ALB: HTTPS or gRPC over :443
+ ALB->>ALB: ACM TLS termination
+ ALB->>ALB: path match /guardian.Guardian/* → gRPC TG :50051 else → HTTP TG :3000
+ ALB->>S: forward (HTTP1.1 or HTTP2/gRPC)
+ S->>SM: GetSecretValue (at startup: DATABASE_URL, ack keys)
+ Note over S,DB: dev path
+ S->>DB: SQL on :5432
+ Note over S,DB: prod path
+ S->>P: SQL on :5432 (TLS required)
+ P->>DB: pooled connection
+ DB-->>S: rows
+ S-->>ALB: response
+ ALB-->>C: response
+```
+
+Health checks: the HTTP target group probes `GET /` ([`alb.tf:59`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L59));
+the gRPC target group probes `/guardian.Guardian/GetPubkey` with matcher `0`
+([`alb.tf:81`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L81)).
+
+## Resource inventory
+
+Mapping AWS resources to the Terraform files that own them:
+
+| AWS resource | Terraform file | Notes |
+|---|---|---|
+| ECS cluster | [`ecs.tf:2`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L2) | Container Insights enabled, ECS Exec logging to CloudWatch. |
+| ECS service `guardian server` | [`ecs.tf:152`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L152) | Fargate, public IP, two target group attachments when HTTPS is on. |
+| ECS task definition | [`ecs.tf:32`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L32) | One container, ports `3000` + `50051`, env + secret env from Secrets Manager. |
+| ECS autoscaling target + policies | [`ecs_autoscaling.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs_autoscaling.tf) | CPU + memory target-tracking, only created when `effective_server_autoscaling_enabled`. |
+| ALB | [`alb.tf:2`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L2) | Internet-facing, at least two subnets enforced as precondition. |
+| HTTP target group (`:3000`) | [`alb.tf:46`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L46) | Health check `GET /`. |
+| gRPC target group (`:50051`) | [`alb.tf:65`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L65) | Created only when an ACM cert is present. |
+| HTTP listener `:80` | [`alb.tf:87`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L87) | Forwards when no cert, redirects to HTTPS when cert is present. |
+| HTTPS listener `:443` | [`alb.tf:121`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L121) | TLS 1.3-1.2 policy; default action → HTTP target group. A migration can temporarily attach a second certificate through [`alb.tf:138`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L138). |
+| gRPC listener rule | [`alb.tf:151`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L151) | Path `/guardian.Guardian/*` → gRPC target group, priority `10`. |
+| RDS Postgres instance | [`rds.tf:20`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L20) | Storage encrypted, backups retained per `rds_backup_retention_days`; prod defaults enable deletion protection and a final snapshot on destroy. |
+| RDS subnet group | [`rds.tf:8`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L8) | Requires ≥2 subnets. |
+| `DATABASE_URL` secret | [`rds.tf:45`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L45) | Always created; consumed by the server task. |
+| RDS Proxy + credentials secret | [`rds.tf:50`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L50), [`rds.tf:72`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L72) | Prod-only via `effective_rds_proxy_enabled`. |
+| RDS Proxy target / pool config | [`rds.tf:108`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L108), [`rds.tf:120`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L120) | 80% max connections, 50% max idle. |
+| Operator public keys secret | [`operator_secrets.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/operator_secrets.tf) | Optional dashboard operator Falcon pubkey list. |
+| ACK Falcon/ECDSA secrets (existing) | [`data.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf) | Looked up via `data` in `prod`; created out-of-band by `aws-deploy.sh bootstrap-ack-keys`. |
+| EVM allowed chains + RPC URLs secrets | [`data.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf) | Optional; populated by deploy script from `config/evm/chains.json`. |
+| ALB SG | [`security_groups.tf:2`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/security_groups.tf#L2) | Ingress `80/443` from `alb_ingress_cidrs`. |
+| Server SG | [`security_groups.tf:35`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/security_groups.tf#L35) | Ingress `3000`/`50051` only from ALB SG; egress all. |
+| RDS Proxy SG | [`security_groups.tf:67`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/security_groups.tf#L67) | Prod-only; ingress `5432` from server SG. |
+| Postgres SG | [`security_groups.tf:92`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/security_groups.tf#L92) | Ingress `5432` from server SG, plus RDS Proxy SG in prod. |
+| ECS task execution role | [`iam.tf:2`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L2) | Pulls images, reads DB / EVM secrets at task start. |
+| ECS task runtime role | [`iam.tf:53`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L53) | App-level `GetSecretValue` for ACK + operator secrets; SSM channels for ECS Exec. |
+| ACK secrets policy | [`iam.tf:70`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L70) | Gated on `local.is_prod` — dev never reads ACK secrets. |
+| Operator pubkeys policy | [`iam.tf:93`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L93) | Created if user supplies an existing ARN or a managed list. |
+| RDS Proxy role | [`iam.tf:136`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L136) | Reads the proxy's credentials secret. |
+| CloudWatch log groups | [`logs.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/logs.tf) | `server` group, `cluster` (ECS Exec) group, and the EMF metrics group when CloudWatch metrics are enabled. |
+| ADOT metrics sidecar + collector config | [`ecs.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf), [`observability.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/observability.tf) | Non-essential container in the server task; config injected via `AOT_CONFIG_CONTENT`. |
+| CloudWatch dashboard + alarms | [`observability.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/observability.tf) | `-server` dashboard; error-rate, latency, canonicalization, metrics-pipeline, and ECS saturation alarms. |
+| ADOT EMF log-write policy | [`iam.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf) | Task-role, stream-level writes on the EMF group only. |
+| Route 53 alias | [`dns.tf:12`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/dns.tf#L12) | Created when `route53_zone_id` is set; hostname migrations may temporarily add a second record. |
+| Cloudflare CNAME | [`dns.tf:27`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/dns.tf#L27) | Created when `cloudflare_zone_id` is set; can be proxied, with the same temporary migration support. |
+| Variables / locals | [`variables.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/variables.tf), [`data.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf) | `local.is_prod`, `effective_*` locals derive the stage profile. |
+
+ECR is **not** managed by Terraform — it is created and pushed to by
+[`scripts/aws-deploy.sh`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh) before
+`terraform apply`.
+
+## Stage profiles
+
+Both stages run from the same Terraform; `deployment_stage` flips a small set
+of `effective_*` locals.
+
+```mermaid
+flowchart TB
+ subgraph Dev["deployment_stage = dev"]
+ DevA["1 ECS task, no autoscaling"]
+ DevB["ECS → RDS direct"]
+ DevC["db.t3.micro, 20 GiB, no autoscale ceiling"]
+ DevD["ACK keys: ephemeral, in-task"]
+ DevE["Conservative rate limits / pool sizes"]
+ end
+
+ subgraph Prod["deployment_stage = prod"]
+ ProdA["≥2 ECS tasks, target-tracking autoscaling"]
+ ProdB["ECS → RDS Proxy → RDS"]
+ ProdC["db.t3.medium, 50 GiB, 200 GiB ceiling"]
+ ProdD["ACK keys: Secrets Manager, imported at startup"]
+ ProdE["Benchmark-grade rate limits / pool sizes"]
+ end
+```
+
+Concrete defaults are in
+[`infra/README.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/README.md#variables-reference) and
+[`docs/SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#stage-profiles).
+
+## Identity and secrets
+
+Five categories of secret participate in a deploy:
+
+1. **`DATABASE_URL`** — written by Terraform from RDS connection details
+ ([`rds.tf:57`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L57)). Server task reads it via the
+ execution role at task start; injected as the `DATABASE_URL` env var
+ ([`ecs.tf:121`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L121)).
+2. **RDS Proxy credentials** (prod) — separate JSON secret consumed by the
+ proxy's IAM role ([`rds.tf:62`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L62),
+ [`iam.tf:155`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L155)).
+3. **ACK signing keys** (prod) — Falcon + ECDSA secret keys for Guardian's
+ own response signing. Created out-of-band by
+ `aws-deploy.sh bootstrap-ack-keys`, referenced via `data` blocks,
+ read by the runtime role ([`iam.tf:70`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L70)) and
+ imported into the filesystem keystore at process start.
+4. **Operator public keys** — Falcon public keys allowed to authenticate to
+ the dashboard. Either Terraform-managed (from a variable list) or an
+ existing ARN; either way exposed as
+ `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ID` to the task
+ ([`ecs.tf:100`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L100)).
+5. **EVM allowed chains + RPC URLs** — Secrets Manager entries optionally
+ populated from `config/evm/chains.json`, exposed to the task as
+ `GUARDIAN_EVM_ALLOWED_CHAIN_IDS` and `GUARDIAN_EVM_RPC_URLS`
+ ([`ecs.tf:125`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L125)).
+
+The IAM split is deliberate:
+- The **execution role** only reads secrets the AWS-ECS agent needs *before*
+ the container starts (DB URL, EVM secrets surfaced as env).
+- The **task role** owns secret reads the *application* performs at runtime
+ (ACK keys, operator pubkeys) and SSM channels for ECS Exec.
+
+## Networking
+
+The stack uses the default VPC by default and picks subnets from
+[`data.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf) unless `vpc_id` / `subnet_ids` are set.
+Public-subnet selection for the ALB sorts subnets lexicographically, which
+can surface a private subnet ahead of a public one if subnet naming differs
+across AZs — pin `subnet_ids` explicitly when you hit AZ surprises. RDS
+Proxy additionally requires explicit opt-in for unsupported AZs via
+`rds_proxy_subnet_ids` (e.g. `us-east-1e` / `use1-az3` in `us-east-1`).
+
+Security-group chain:
+```text
+internet → alb SG (80/443) → server SG (3000, 50051)
+server SG ─┬─→ postgres SG (5432) (dev path)
+ └─→ rds_proxy SG (5432) → postgres SG (5432) (prod path)
+```
+
+Egress is wide-open from the server SG so the task can pull images, talk to
+Secrets Manager, talk to Miden RPC, and emit CloudWatch logs.
+
+## Deploy lifecycle
+
+```mermaid
+flowchart LR
+ A["scripts/aws-deploy.sh deploy"] --> B["docker build & push → ECR"]
+ B --> C["build tfvars from env"]
+ C --> D["terraform init/apply (split state per stack+stage)"]
+ D --> E["ECS service rolls task definition revision"]
+ E --> F["task pulls image, reads secrets, starts HTTP+gRPC servers"]
+ F --> G["ALB health checks pass → traffic shifts"]
+```
+
+State is kept **locally** per stack+stage at
+`infra/terraform...tfstate`. There is no remote backend
+configured; the deploy script is the source of truth for which state file is
+in use.
+
+## Observability surface
+
+Today: CloudWatch container logs ([`logs.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/logs.tf)),
+Container Insights metrics ([`ecs.tf:6`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L6)),
+ECS Exec session logging ([`ecs.tf:10`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L10)), and
+Terraform-managed application metrics
+([`observability.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/observability.tf)): an ADOT Collector
+sidecar in the server task scrapes Guardian's loopback-only Prometheus
+endpoint and exports selected metrics to CloudWatch via EMF under a
+per-stack namespace, with a `-server` dashboard and alarms for
+error rate, latency, canonicalization failures, metrics-pipeline health,
+and ECS saturation. Gated by `guardian_metrics_enabled` (the endpoint)
+and `cloudwatch_metrics_enabled` (the export pipeline), both on by
+default; enablement and verification live in
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#metrics-dashboard-and-alarms).
+Tracing exporters remain an open gap.
+
+## Things that are deliberately not here
+
+- **No remote Terraform backend.** State files are local; the deploy script
+ treats them as authoritative. Switch to S3+DynamoDB before multiple
+ operators apply concurrently.
+- **No WAF, no Shield Advanced.** The ALB is reachable from
+ `alb_ingress_cidrs`, default `0.0.0.0/0`.
+- **No RDS read replica, no automated DR drill.** Backups are
+ configured via `rds_backup_retention_days` (default 7) and the manual
+ restore procedure is documented in
+ [`runbooks/backup-restore.md`](../runbooks/backup-restore.md), but there
+ is no rehearsed, automated DR path. Multi-AZ standby failover is opt-in
+ via `rds_multi_az` (off by default).
+- **No KMS-managed Secrets Manager keys.** Secrets use the default AWS-owned
+ key. Rotation is manual.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/architecture/services.md b/versioned_docs/version-0.16/builder/miden-guardian/architecture/services.md
new file mode 100644
index 00000000..8eb93946
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/architecture/services.md
@@ -0,0 +1,371 @@
+---
+title: "Guardian Service Architecture"
+sidebar_position: 1
+---
+This document describes the logical decomposition of the Guardian system —
+the modules inside the server process and the clients/SDKs that talk to it.
+It complements [`docs/architecture/infra.md`](./infra.md), which covers how
+that same process is packaged and deployed on AWS.
+
+## TL;DR
+
+Guardian is one Rust binary
+([`crates/server`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server)) that fronts two transports
+(HTTP + gRPC) over a shared service layer, persists Miden account state and
+deltas in Postgres, signs its responses with ACK keys, and is consumed by
+Rust and TypeScript clients plus the multisig SDKs that build on top.
+
+## Layered view
+
+```mermaid
+flowchart TB
+ subgraph Clients["Consumers"]
+ RC["crates/client Rust client"]
+ TC["packages/guardian-client TS client (HTTP REST)"]
+ OC["packages/guardian-operator-client TS operator client"]
+ EC["packages/guardian-evm-client TS EVM client"]
+ RM["crates/miden-multisig-client Rust multisig SDK"]
+ TM["packages/miden-multisig-client TS multisig SDK"]
+ end
+
+ subgraph Server["crates/server (Guardian)"]
+ direction TB
+
+ subgraph API["api/ — transports"]
+ HTTP["http.rs axum + REST + dashboard"]
+ GRPC["grpc.rs tonic + guardian.proto"]
+ DASH["dashboard.rs + dashboard_feeds.rs"]
+ EVMAPI["evm.rs"]
+ end
+
+ subgraph MW["middleware/"]
+ AUTH["request auth, rate limit, CORS, request ID, audit"]
+ end
+
+ subgraph SVC["services/ — request handlers"]
+ DELTA["push_delta · get_delta · get_delta_since delta_commit"]
+ PROP["push/get/sign_delta_proposal"]
+ STATE["get_state · lookup_account · configure_account"]
+ DASHSVC["dashboard_account_* · dashboard_global_* dashboard_info · pagination"]
+ end
+
+ subgraph DOM["domain"]
+ DO["delta_object.rs"]
+ SO["state_object.rs"]
+ NET["network/ — Miden RPC client glue"]
+ EVMDOM["evm/ — proposals, sessions, contracts"]
+ JOBS["jobs/ — canonicalization, async tasks"]
+ AUDIT["audit/ — append-only event log"]
+ end
+
+ subgraph PERSIST["Persistence + identity"]
+ STG["storage/ — state + deltas (postgres / filesystem)"]
+ META["metadata/ — accounts, auth, network (postgres / filesystem)"]
+ ACK["ack/ — Falcon + ECDSA signing (keystore + Secrets Manager)"]
+ end
+
+ HTTP --> AUTH
+ GRPC --> AUTH
+ DASH --> AUTH
+ EVMAPI --> AUTH
+ AUTH --> SVC
+ SVC --> DOM
+ SVC --> PERSIST
+ DOM --> PERSIST
+ SVC --> ACK
+ end
+
+ RC --> GRPC
+ TC --> HTTP
+ OC --> HTTP
+ EC --> EVMAPI
+ RM --> RC
+ TM --> TC
+ TM --> OC
+
+ PERSIST <--> DB[("Postgres")]
+ NET <--> MIDEN[("Miden RPC node")]
+ ACK <--> SM[("AWS Secrets Manager (prod)")]
+```
+
+Sources for the boxes: server module declarations in
+[`crates/server/src/lib.rs:3-30`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/lib.rs#L3),
+API submodules in
+[`crates/server/src/api/mod.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/mod.rs),
+service handlers in
+[`crates/server/src/services/mod.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/services/mod.rs).
+
+## Components
+
+### Transports — `api/`
+
+Two transports, one set of service handlers. Default ports are `:50051`
+(gRPC) and `:3000` (HTTP/dashboard); both are configurable via the server
+builder.
+
+- **gRPC** ([`api/grpc.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/grpc.rs)) is the
+ primary surface for the Rust client. Schema lives in
+ `crates/server/proto/guardian.proto`. Default bind: `:50051`.
+- **HTTP** ([`api/http.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/http.rs)) hosts the
+ REST routes for the TypeScript clients **and** the operator dashboard.
+ Default bind: `:3000`.
+ `axum` based. The TS Guardian client (`@openzeppelin/guardian-client`)
+ is HTTP-only — it does **not** speak gRPC. This Rust-vs-TS transport
+ split is the most common integration pitfall in the codebase.
+- **Dashboard** ([`api/dashboard.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/dashboard.rs),
+ [`api/dashboard_feeds.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/dashboard_feeds.rs))
+ carries operator endpoints: accounts list, account detail, proposals,
+ deltas, snapshot, info. Uses session auth distinct from Guardian's
+ per-account auth.
+- **EVM** ([`api/evm.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/evm.rs)) is the
+ feature-gated EVM proposal API exposed when the server is built with the
+ `evm` feature.
+
+Both transports share the **middleware** stack
+([`crates/server/src/middleware`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/middleware)):
+request authentication, rate limiting, CORS, request IDs, audit-log
+emission.
+
+### Service handlers — `services/`
+
+Per-RPC handlers. Each file owns one verb of the Guardian contract.
+
+| Service file | Purpose |
+|---|---|
+| `push_delta.rs` | Apply a signed delta to an account's state. |
+| `get_delta.rs`, `get_delta_since.rs` | Read deltas by id / by cursor. |
+| `delta_commit.rs` | Final commit step turning a proposal into a delta. |
+| `push_delta_proposal.rs`, `get_delta_proposal*.rs`, `sign_delta_proposal.rs` | Multisig proposal lifecycle. |
+| `get_state.rs`, `lookup_account.rs`, `configure_account.rs` | State and account lifecycle. |
+| `dashboard_account_*.rs`, `dashboard_global_*.rs`, `dashboard_info.rs`, `dashboard_pagination.rs` | Operator dashboard queries. |
+
+The transport layer (gRPC or HTTP) decodes the request, the middleware
+authenticates and audits it, then dispatches into one of these handlers.
+
+### Domain types
+
+- **`state_object.rs` + `delta_object.rs`** ([`crates/server/src/state_object.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/state_object.rs),
+ [`crates/server/src/delta_object.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/delta_object.rs))
+ — canonical in-memory shapes for an account's state and the deltas applied
+ to it. The proto-level shapes are normalized into these before any
+ persistence touches them.
+- **`network/`** — wraps the Miden RPC client used to verify on-chain
+ proofs / submit transactions.
+- **`evm/`** ([`crates/server/src/evm`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/evm)) —
+ EVM proposal lifecycle, session auth, on-chain contract interaction.
+ Feature-gated.
+- **`jobs/`** ([`crates/server/src/jobs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/jobs)) —
+ background work, currently canonicalization tasks that normalize proposals
+ before commit.
+- **`audit/`** ([`crates/server/src/audit`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/audit))
+ — append-only structured event log written alongside mutating operations.
+
+### Persistence — `storage/` and `metadata/`
+
+Two parallel module trees, each with a `postgres.rs` and `filesystem.rs`
+backend selected at startup:
+
+- **`storage/`** ([`crates/server/src/storage`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/storage))
+ owns the heavy data: account state objects and delta history.
+- **`metadata/`** ([`crates/server/src/metadata`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata))
+ owns accounts, auth credentials
+ ([`metadata/auth/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth)), and
+ network/peer info.
+
+In production both back ends are Postgres on RDS, sharing pool sizing
+controlled by `GUARDIAN_DB_POOL_MAX_SIZE` and
+`GUARDIAN_METADATA_DB_POOL_MAX_SIZE` (see
+[`infra.md`](./infra.md#stage-profiles)). The filesystem backends are kept
+for local development and tests.
+
+### Storage modes
+
+The backend is selected at **compile time** via Cargo features in
+[`crates/server/src/builder/storage.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs) —
+not at runtime, not via env vars. Pick the right feature set for your build
+target.
+
+| Mode | Feature | Storage | Metadata | Audit | Required env |
+|---|---|---|---|---|---|
+| **Postgres** (prod, staging) | `postgres` | `PostgresService` | `PostgresMetadataStore` | `PostgresAuditor` — durable rows in `admin_actions` | `DATABASE_URL` |
+| **Filesystem** (dev, tests) | _none_ | `FilesystemService` | `FilesystemMetadataStore` | `LogAuditor` — structured logs only, **no durable audit** | `GUARDIAN_STORAGE_PATH`, `GUARDIAN_METADATA_PATH` |
+
+```mermaid
+flowchart LR
+ Build["cargo build --features ?"]
+
+ Build -->|"--features postgres"| PG
+ Build -->|"no features"| FS
+
+ subgraph PG["Postgres mode (prod)"]
+ PGS["PostgresService (state, deltas)"]
+ PGM["PostgresMetadataStore (accounts, auth, network)"]
+ PGA["PostgresAuditor (durable admin_actions)"]
+ PGS --> RDS[("RDS PostgreSQL")]
+ PGM --> RDS
+ PGA --> RDS
+ end
+
+ subgraph FS["Filesystem mode (dev/test)"]
+ FSS["FilesystemService"]
+ FSM["FilesystemMetadataStore"]
+ FSA["LogAuditor ⚠ warns at startup: 'audit events will not be persisted'"]
+ FSS --> Disk[("local filesystem")]
+ FSM --> Disk
+ end
+```
+
+#### Production must use Postgres
+
+Filesystem mode is unsafe for production for three reasons:
+
+1. **No durable audit trail.** The filesystem build wires up `LogAuditor`
+ and emits a one-shot startup warning that audit events flow only to
+ structured logs ([`builder/storage.rs:129-137`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs#L129)).
+ Postgres mode persists them in the `admin_actions` table.
+2. **No migrations, no schema guarantees.** The Postgres path runs
+ `postgres::run_migrations` at startup
+ ([`builder/storage.rs:109`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs#L109));
+ the filesystem path has none.
+3. **No horizontal scaling.** Multiple ECS tasks behind the ALB cannot
+ share filesystem state — each task would diverge. Postgres on RDS is the
+ only shared substrate in the deployment topology
+ ([`infra.md`](./infra.md#topology)).
+
+The AWS deploy path therefore builds with `GUARDIAN_SERVER_FEATURES=postgres`
+(plus `evm` when needed). See
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#quick-start).
+
+#### When filesystem mode is fine
+
+- Single-process examples under [`examples/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples).
+- Unit and integration tests that need a real backend but not a real DB.
+- Quick local iteration on non-storage code paths before spinning up Postgres.
+
+For local development that exercises Postgres behavior, run a local
+Postgres (e.g. `docker run -e POSTGRES_PASSWORD=… postgres`) and build with
+`--features postgres` rather than relying on the filesystem backend.
+
+### Identity — `ack/`
+
+Guardian signs its responses so that clients (and the multisig SDKs in
+particular) can verify the server is the same Guardian they trust.
+
+- [`ack/mod.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/mod.rs) wires the signer.
+- [`ack/miden_falcon_rpo/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/miden_falcon_rpo)
+ and [`ack/miden_ecdsa/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/miden_ecdsa) hold the
+ two scheme implementations.
+- [`ack/secrets_manager.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/secrets_manager.rs)
+ pulls secret payloads into the filesystem keystore at startup. In `dev`
+ keys are auto-generated; in `prod` they are loaded from the Secrets
+ Manager IDs in `GUARDIAN_ACK_FALCON_SECRET_ID` /
+ `GUARDIAN_ACK_ECDSA_SECRET_ID`, falling back to
+ `guardian-prod/server/ack-{falcon,ecdsa}-secret-key` when unset
+ ([`secrets_manager.rs:10-13`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/secrets_manager.rs#L10)).
+ Terraform sets these env vars per stack so multi-stack deployments use
+ scoped IDs.
+
+### Dashboard subsystem
+
+The dashboard layer is a small system in its own right inside
+[`crates/server/src/dashboard`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard):
+its own session/auth ([`authz.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/authz.rs),
+[`middleware.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/middleware.rs)),
+allowlist of operator public keys ([`allowlist.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/allowlist.rs)),
+permission model ([`permissions.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/permissions.rs)),
+shared state ([`state.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/state.rs)),
+and pagination cursor logic
+([`cursor.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/cursor.rs)).
+
+It piggybacks on the same Postgres backend as the rest of the server but
+authenticates operators through Falcon-signed challenges rather than the
+per-account credentials used by Guardian's primary API.
+
+## Consumers
+
+```mermaid
+flowchart LR
+ subgraph Rust["Rust SDKs"]
+ GC["crates/client Guardian client (gRPC)"]
+ MM["crates/miden-multisig-client multisig SDK"]
+ KS["crates/miden-keystore local key storage"]
+ MR["crates/miden-rpc-client Miden node client"]
+ end
+
+ subgraph TS["TS SDKs (packages/)"]
+ GTS["guardian-client"]
+ GTOP["guardian-operator-client"]
+ GTEVM["guardian-evm-client"]
+ MTS["miden-multisig-client"]
+ end
+
+ subgraph Apps["Examples / harnesses"]
+ DEMO["examples/demo (Rust)"]
+ SMOKE["examples/smoke-web, operator-smoke-web, evm-smoke-web"]
+ WEB["examples/web"]
+ end
+
+ MM --> GC
+ MM --> KS
+ MM --> MR
+ MTS --> GTS
+ GTOP -. operator HTTP .-> Server
+ GTS -. HTTP/REST .-> Server
+ GTEVM -. EVM HTTP .-> Server
+ GC -. gRPC .-> Server
+ DEMO --> MM
+ SMOKE --> MTS
+ SMOKE --> GTOP
+ SMOKE --> GTEVM
+ WEB --> MTS
+
+ Server[(crates/server)]
+```
+
+- **Rust client** ([`crates/client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/client)) — gRPC client with
+ auth ([`crates/client/src/auth`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/client/src/auth)) and a
+ pluggable keystore ([`crates/client/src/keystore`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/client/src/keystore)).
+- **Rust multisig SDK** ([`crates/miden-multisig-client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/miden-multisig-client))
+ — builds on the Rust client; owns proposal creation, signing, execution,
+ export/import, and the `SwitchGuardian` flow.
+- **TS Guardian client** ([`packages/guardian-client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/packages/guardian-client))
+ — **HTTP REST** client (`GuardianHttpClient`) used directly by browser
+ apps and by the TS multisig SDK. Targets the server's HTTP routes on
+ `:3000`, **not** gRPC. This is the common transport split to be aware
+ of: the Rust client speaks gRPC; the TS client speaks HTTP.
+- **TS multisig SDK** ([`packages/miden-multisig-client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/packages/miden-multisig-client))
+ — TS counterpart of the Rust multisig SDK; browser-friendly.
+- **Operator client** ([`packages/guardian-operator-client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/packages/guardian-operator-client))
+ — talks to the dashboard endpoints (HTTP, not gRPC).
+- **EVM client** ([`packages/guardian-evm-client`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/packages/guardian-evm-client))
+ — talks to `api/evm.rs` when the server is built with EVM support.
+
+Smoke harnesses under [`examples/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples) drive each SDK
+end-to-end; see the matching `smoke-test-*` skills for how to run them.
+
+## Authentication shape
+
+Two distinct auth domains:
+
+1. **Per-account auth** — every mutating Guardian RPC is signed by the
+ account's Falcon or ECDSA key. Verified in the auth middleware via
+ [`metadata/auth/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth) which loads
+ credentials from the metadata store and dispatches to the right scheme
+ ([`miden_falcon_rpo.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth/miden_falcon_rpo.rs),
+ [`miden_ecdsa.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth/miden_ecdsa.rs)).
+2. **Operator auth** — dashboard endpoints use Falcon-signed challenges
+ against an allowlist of operator public keys, producing session cookies.
+ Lives entirely in
+ [`dashboard/authz.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/authz.rs).
+
+Both paths share the same ACK signer when emitting *responses*; only the
+*incoming* credentials differ.
+
+## Where to look next
+
+- For build-and-deploy: [`docs/SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md).
+- For the deployment topology: [`docs/architecture/infra.md`](./infra.md).
+- For the multisig SDK: [`docs/MULTISIG_SDK.md`](../reference/multisig-sdk.md).
+- For protocol-level contracts: `crates/server/proto/guardian.proto`. When
+ changing the proto, regenerate language bindings and re-run the SDK smoke
+ tests in `examples/` before publishing.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/getting-started/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/_category_.json
new file mode 100644
index 00000000..3562d433
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Getting Started",
+ "position": 1
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/getting-started/concepts.md b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/concepts.md
new file mode 100644
index 00000000..be75d4fb
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/concepts.md
@@ -0,0 +1,282 @@
+---
+title: "Guardian Concepts"
+sidebar_position: 1
+---
+A conceptual introduction to Guardian. Read this **before** the architecture
+docs if you are new to the system — it explains what Guardian is, what it is
+*not*, and the trust model that informs every other decision in the
+codebase.
+
+For formal definitions and wire shapes, see [`spec/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/index.md). For
+the module-level decomposition, see
+[`docs/architecture/services.md`](../architecture/services.md).
+
+## What Guardian is
+
+Guardian is an **off-chain coordination service** for Miden accounts. It
+stores account state snapshots and the deltas that mutate them, signs
+accepted deltas with an acknowledgement key, and helps multiple clients of
+the same account stay in sync.
+
+Guardian is:
+
+- **Non-custodial.** It never holds an account's spending key. The account
+ owner does.
+- **Not the source of truth.** The Miden network remains authoritative for
+ account commitments. Guardian's database is a *coordination cache*, not a
+ ledger.
+- **Pluggable.** Server, clients, and the multisig SDK are all in this
+ repository; operators can run Guardian themselves, and users can rotate
+ away from any operator at any time.
+
+## The custody spectrum
+
+Traditional crypto custody is binary — either a full custodian holds the
+key, or the user does. Guardian creates a third position — but **not** by
+holding a seat in the user's multisig. The account component enforces two
+independent checks on every ordinary transaction:
+
+```mermaid
+flowchart LR
+ subgraph S["User signer set"]
+ H["Hot key (daily transactions)"]
+ C["Cold key (recovery, offline)"]
+ end
+ T["M-of-N threshold (user keys only)"]
+ G["Guardian check (one ACK signature — never counted toward M)"]
+ B{"Both must pass"}
+ A["Transaction authorized"]
+ H --> T
+ C --> T
+ T --> B
+ G --> B
+ B --> A
+```
+
+- **The user threshold** counts only the user's signer set. The Guardian
+ key is stored in a separate storage slot and can never satisfy or
+ contribute to M.
+- **The Guardian check** verifies exactly one Guardian signature over the
+ same transaction summary the cosigners sign. It is a pass/fail gate:
+ the Guardian can veto a transaction by withholding its ACK, but its
+ signature alone authorizes nothing — **Guardian alone can never move
+ funds**.
+- **The rotation exception.** The one transaction the account component
+ executes without the Guardian check is `SwitchGuardian` (rotating the
+ Guardian key), which needs only the user threshold. Guardian's blocking
+ power is therefore temporary and defeasible: the user's own keys can
+ always remove it.
+
+So the Guardian participates in every ordinary transaction, but as a
+separate authentication input — not as one of the M counted signatures.
+Describing the account as "2-of-3" (user hot, user cold, Guardian)
+understates the user threshold's independence and overstates Guardian's
+authority; the accurate description is *M-of-N over user keys, plus a
+removable Guardian gate*.
+
+## State and Delta
+
+The two primitives Guardian works with:
+
+- **State** — a snapshot of an account at a point in time. Guardian
+ tracks the account ID, current commitment, nonce, and authentication
+ scheme. The full state payload is opaque to Guardian; the client
+ supplies it.
+- **Delta** — an append-only change to that state. Every delta
+ references the previous commitment (`prev_commitment`) and produces a
+ new one, forming an unbroken chain.
+
+These mirror the definitions in
+[`spec/index.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/index.md#definitions). The supporting concepts
+are:
+
+| Term | Role |
+|---|---|
+| **Commitment** | Hash uniquely identifying a state version. Lets clients detect tampering. |
+| **Nonce** | Monotonically increasing counter — orders deltas in the chain. |
+| **Account ID** | Unique identifier; one Guardian hosts many accounts. |
+| **Delta proposal** | Multi-party coordination object — sits in `pending` until threshold cosigners have signed. |
+| **Acknowledgement (ACK)** | Guardian's signature over an accepted delta's transaction summary commitment — the same message the cosigners sign. Issued only after Guardian has validated the delta against the stored state. Clients verify the ACK to confirm a delta was actually accepted by the Guardian they expected. |
+
+## Transaction lifecycle
+
+A single transaction touches Guardian and the Miden network in five steps:
+
+```mermaid
+sequenceDiagram
+ participant U as User device
+ participant G as Guardian
+ participant M as Miden network
+
+ U->>U: 1. Execute transaction locally, compute delta
+ U->>G: 2. Submit signed delta (prev_commitment, nonce, payload)
+ G->>G: validate against stored state
+ G-->>U: 3. ACK signature over the transaction summary commitment (delta status: candidate)
+ U->>M: 4. Submit proven account update
+ M-->>U: account commitment accepted
+ G->>M: poll for canonical commitment
+ G->>G: 5. If commitments match, mark canonical — otherwise park as retained and keep reconciling until it matches or the TTL expires
+```
+
+The status transitions for a delta:
+
+| Status | Meaning |
+|---|---|
+| `candidate` | Guardian accepted and signed it, but the matching Miden update has not yet been observed. |
+| `canonical` | Guardian observed the matching commitment on Miden. The delta is now durable for other clients of the same account. |
+| `retained` | Guardian stopped actively verifying the candidate and released the account slot. The on-chain outcome remains **uncertain**; background reconciliation may still promote it to `canonical` until its retention TTL expires (default 24 h). A new submission at the same nonce supersedes it. Never read `retained` as "the transaction did not land" — it means *unlocked but unresolved*. |
+| `discarded` | Canonicalization was abandoned by the client (`client_abandoned`), or retention is disabled and verification failed terminally. The client must rebuild from the latest canonical state. |
+
+This single definition of `retained` holds across every surface —
+canonicalization, account locking, the abandon APIs (which report a
+distinct `retained` result, never `abandoned`), the SDKs, and the
+operator dashboard. At a glance:
+
+| Status | Account locked? | Outcome known? | Client action |
+|---|---|---|---|
+| `candidate` | Yes | No | Wait, or request abandonment |
+| `retained` | No | No | Sync and check the chain before replacing (a resubmission supersedes the row and forfeits automatic recovery) |
+| `discarded` (`client_abandoned`) | No | Probably not landed, but late reconciliation remains possible within the TTL | Continue cautiously |
+| `canonical` | No | Yes — landed | Sync account state |
+
+This is the **canonicalization** process. The default `candidate` mode runs
+a background worker that polls Miden and promotes or discards each
+candidate. An `optimistic` mode promotes immediately and is appropriate
+only when the client and operator trust each other absolutely (e.g.
+single-tenant dev setups). See [`spec/processes.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/processes.md#canonicalization)
+for the formal state machine.
+
+## Trust model
+
+Guardian's trust boundaries layer up like this:
+
+```mermaid
+flowchart LR
+ subgraph L1["1. Client → Guardian"]
+ L1d["signed delta, signed request, timestamp, payload digest"]
+ end
+ subgraph L2["2. Guardian → Client"]
+ L2d["ACK-signed delta, commitment chain"]
+ end
+ subgraph L3["3. Client → Miden"]
+ L3d["ZK-proven account update"]
+ end
+ subgraph L4["4. Guardian → Miden"]
+ L4d["read-only commitment queries"]
+ end
+ subgraph L5["5. Inside Guardian"]
+ L5d["snapshots, deltas, proposals, metadata, audit log"]
+ end
+
+ Client --> L1 --> L2 --> Client
+ Client --> L3 --> Miden
+ Guardian --> L4 --> Miden
+ Guardian --> L5
+```
+
+| Boundary | Protected by |
+|---|---|
+| Client → Guardian | Per-account Falcon/ECDSA signatures, replay protection (±5 min timestamp window + monotonic per-key timestamps), rate limits, request size limits. |
+| Guardian → Client | The ACK signature on every accepted delta. Clients verify the ACK and the commitment chain before trusting returned state. |
+| Client → Miden | Miden's own ZK proof verification; Guardian is not in this path. |
+| Guardian → Miden | Read-only RPC; Miden does not trust Guardian for anything. |
+| Inside Guardian | Backend access control, IAM scoping, infrastructure hardening — see [`docs/runbooks/secrets.md`](../runbooks/secrets.md) and [`docs/architecture/infra.md`](../architecture/infra.md). |
+
+What this means in practice:
+
+- **A compromised Guardian cannot steal funds.** It can refuse service or
+ serve stale data, but it cannot produce a Miden-accepted update without
+ the user's spending key.
+- **A compromised Guardian can withhold or lie about state.** Clients are
+ expected to compare against Miden before signing anything important.
+- **An offline Guardian halts coordination.** Users can still execute
+ locally; they just cannot sync with their other devices, and other
+ cosigners cannot see their proposals.
+
+## Client verification checklist
+
+When integrating an SDK against a Guardian, clients **must**:
+
+1. **Pin Guardian's pubkey** — fetch `/pubkey` once over a trusted channel
+ and refuse to talk to any Guardian that returns a different key.
+2. **Verify the ACK signature** on every accepted delta before treating it
+ as confirmed.
+3. **Validate the commitment chain** — `delta_n.new_commitment` must match
+ `delta_{n+1}.prev_commitment`. A break means Guardian is lying or
+ corrupted.
+4. **Check freshness against Miden** before signing high-value
+ transactions — match the latest canonical commitment against the
+ account's commitment on-chain.
+5. **Treat unexpected pubkey changes as security events.** The Guardian
+ you connected to last week should be the same Guardian today. If it
+ isn't, halt operations until you can confirm intentional rotation.
+
+The Rust and TypeScript multisig SDKs perform 1–4 automatically; #5 is an
+application-level decision.
+
+## Failure and recovery
+
+```mermaid
+flowchart TB
+ F1["Guardian unavailable"] --> R1["Use local state · retry · rotate provider"]
+ F2["Candidate fails canonicalization"] --> R2["Resync from latest canonical · rebuild transaction"]
+ F3["Stale delta submission (prev_commitment mismatch)"] --> R3["Fetch /delta/since · replay canonical deltas · retry"]
+ F4["Operator withholds updates"] --> R4["Compare against Miden · rotate Guardian (user threshold)"]
+ F5["Guardian database corruption"] --> R5["Reject unverifiable data · recover from another device or operator"]
+```
+
+| Failure | What you see | Recovery |
+|---|---|---|
+| Guardian unreachable | gRPC `Unavailable` / HTTP 5xx, no ACK | Continue locally, retry; rotate operator if persistent. |
+| Stale delta (`commitment_mismatch`) | `400` with `code: commitment_mismatch` | `GET /delta/since` → replay canonical chain → retry the local transaction. |
+| Candidate parked (`retained`) | Delta status flips `candidate` → `retained`; the account is released | Usually means the Miden proof was never submitted, the on-chain commitment diverged, or the guardian's RPC view lagged. No action is strictly required: if the transaction actually landed, the guardian reconciles and promotes it automatically. To move on immediately, refetch state, rebuild and resubmit — a new submission at the same nonce supersedes the retained delta. Because superseding forfeits that automatic recovery, check the delta's status once before resubmitting: if it already flipped to `canonical`, the original transaction landed and there is nothing to redo. |
+| Transaction died after approval (stranded candidate) | New proposals answered `409 conflict_pending_delta` while the candidate waits out the grace + retry window | Call `POST /delta/candidate/abandon` (SDKs: `abandonCandidate` / `abandon_candidate`). The worker confirms over a short quarantine that the transaction did not land, flips the delta to `discarded` with reason `client_abandoned`, and releases the account — typically well under a minute. Poll via `abandonStatus` / `abandon_status`. |
+| Operator censors / withholds | Other cosigners see stale state | Rotate Guardian by meeting the applicable user threshold (the cold key can participate); the new operator inherits canonical state from Miden. |
+| Guardian database corruption (or restore from an older backup) | Accounts whose on-chain commitment advanced past the stored state fail state verification; accounts onboarded after the restore point fail with `account_not_found` because no guardian record remains | For an advanced account, a device holding the newer state re-syncs it, or rotate to another operator. For a missing account, a device holding it re-onboards via `/configure`, which re-registers the account with the state that device holds. The guardian cannot regenerate lost deltas because guarded accounts are private. |
+| Account paused by operator | State-transition, proposal, and EVM mutation paths return `409 GUARDIAN_ACCOUNT_PAUSED` with `paused_reason` (reads and `ConfigureAccount` keep working) | Operator-driven safety lever, not a fault. An operator with `accounts:pause` clears it via `POST /dashboard/accounts/{id}/unpause`. See [`DASHBOARD.md`](../operations/dashboard.md#account-pausing). |
+| Account switched to another guardian | After the `switch_guardian` delta canonicalizes on this server, mutation paths return `409 GUARDIAN_ACCOUNT_RELEASED` with `released_at` (reads and `ConfigureAccount` keep working); the dashboard shows `released_at` | Expected outcome of a guardian switch, not a fault. Terminal until the wallet re-onboards via `/configure`, which re-validates the guardian binding. An operator unpause never reactivates a released account. |
+| Pubkey changed unexpectedly | `/pubkey` returns a key your client doesn't pin | Treat as compromise. Halt, verify rotation through an out-of-band channel. |
+
+## Provider rotation
+
+Because Guardian is non-custodial and Miden is the source of truth, users
+who can meet their account's signing threshold can switch from one Guardian
+operator to another without the current operator's cooperation:
+
+1. Stand up (or contract with) a new Guardian instance.
+2. Meet the applicable user threshold to execute the `SwitchGuardian`
+ transaction, which installs the new Guardian service key in the
+ account's Guardian slot. This is the rotation exception
+ described above: the account component accepts it without the current
+ Guardian's signature.
+3. Point clients at the new endpoint and pubkey.
+
+The multisig SDK's `SwitchGuardian` flow implements this. See
+[`docs/MULTISIG_SDK.md`](../reference/multisig-sdk.md).
+
+## What Guardian is *not*
+
+To avoid confusion when reading the architecture docs:
+
+- **Not a custodian.** Cannot move funds.
+- **Not a node.** Does not validate Miden transactions. It signs *deltas*
+ (off-chain state changes); Miden verifies the on-chain proof.
+- **Not authoritative.** If Guardian and Miden disagree, Miden wins.
+ Guardian discards mismatched candidates by design.
+- **Not a TEE-only system.** Reproducible builds make TEE deployment
+ *possible* but the reference deployment runs on ECS/Fargate, not in a
+ TEE.
+- **Not a privacy layer.** Guardian operators can see metadata (account
+ IDs, timestamps, delta size, frequency). Payload privacy comes from
+ Miden's ZK execution, not from Guardian.
+
+## Where to read next
+
+- [Local development](./local-dev.md) — get a Guardian running locally.
+- [Service architecture](../architecture/services.md) — the modules that
+ implement everything above.
+- [AWS deployment](../architecture/infra.md) — the reference production
+ topology.
+- [Troubleshooting](../operations/troubleshooting.md) — error codes and recovery
+ playbooks.
+- [`spec/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/index.md) — formal specification.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/getting-started/local-dev.md b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/local-dev.md
new file mode 100644
index 00000000..0a7c82ab
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/local-dev.md
@@ -0,0 +1,275 @@
+---
+title: "Local Development"
+sidebar_position: 3
+---
+How to run Guardian on a developer machine, what choices you have, and which
+example to reach for once it's up.
+
+For protocol concepts (State, Delta, Nonce, Commitment) read
+[`spec/index.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/index.md) first — this guide assumes you know them.
+For the deployed AWS topology see
+[`docs/architecture/infra.md`](../architecture/infra.md).
+
+## What you're choosing
+
+Three decisions when running Guardian locally:
+
+1. **Storage backend** — filesystem (default) or Postgres. See
+ [Storage modes](../architecture/services.md#storage-modes). Filesystem is
+ fine for most local work; pick Postgres only if you are testing
+ migrations, audit persistence, or multi-replica behavior.
+2. **Cargo features** — `postgres` and/or `evm`. Default builds do
+ **not** include EVM routes and do **not** include the Postgres backend.
+3. **How to launch** — `cargo run` (fastest iteration) or
+ `docker compose` (closer to the deployed shape).
+
+## Prerequisites
+
+- Rust toolchain pinned by [`rust-toolchain.toml`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/rust-toolchain.toml).
+- Node 24+ (bundles npm 11, which the `packages/` workspace lockfile requires) if you will run any TS examples or packages.
+- Docker if you will use `docker-compose.*.yml`.
+- A Miden node — required for almost every flow. Either point at a
+ Miden Devnet endpoint or run one locally; configure via
+ `GUARDIAN_NETWORK_TYPE`. A node on a non-default host or port (for
+ example a sidecar container) is reachable via
+ `GUARDIAN_MIDEN_RPC_ENDPOINT` without changing the network type; see
+ [CONFIGURATION.md](../reference/configuration.md) for that and the optional
+ `GUARDIAN_MIDEN_RPC_TIMEOUT_MS` / `GUARDIAN_MIDEN_RPC_MAX_ATTEMPTS`
+ read-retry knobs. The node's Miden line must match the workspace
+ baseline (currently the 0.16 pre-release line; devnet already runs
+ the 0.16 node) — a mismatched node is rejected at the RPC boundary
+ (see
+ [Troubleshooting](../operations/troubleshooting.md#client-and-node-disagree-about-the-network-version)).
+
+## Environment file
+
+The server calls `dotenvy::dotenv()` on startup, so `cargo run --bin server`
+automatically reads a root `.env` file when one exists. In practice a `.env`
+file (or exported variables) is required: `GUARDIAN_NETWORK_TYPE` has no
+default and the server refuses to start without it, and the built-in
+filesystem paths live under `/var/guardian`, which may not exist or be
+writable on a developer machine.
+
+Minimal local `.env`:
+
+```bash
+GUARDIAN_STORAGE_PATH=.guardian/storage
+GUARDIAN_METADATA_PATH=.guardian/metadata
+GUARDIAN_KEYSTORE_PATH=.guardian/keystore
+GUARDIAN_NETWORK_TYPE=MidenDevnet
+RUST_LOG=info
+```
+
+Create the directories once before the first run:
+
+```bash
+mkdir -p .guardian/storage .guardian/metadata .guardian/keystore
+```
+
+Use `.env.example` as a broader template when you need deploy variables,
+Postgres, dashboard operators, or EVM settings. Docker Compose does not inject
+the root `.env` into the server container by default; the checked-in compose
+file already sets the container filesystem paths.
+
+## Path A — `cargo run` with filesystem (fastest)
+
+```bash
+cargo run --bin server
+```
+
+This builds with no extra features and uses the **filesystem** backend.
+Useful env:
+
+| Variable | Notes |
+|---|---|
+| `GUARDIAN_STORAGE_PATH` | Local path for state + deltas. Defaults to `/var/guardian/storage`. |
+| `GUARDIAN_METADATA_PATH` | Local path for accounts, auth, network. Defaults to `/var/guardian/metadata`. |
+| `GUARDIAN_KEYSTORE_PATH` | ACK key files, auto-generated on first run. Defaults to `/var/guardian/keystore`. |
+| `RUST_LOG` (`info`) | `info`, `debug`, or e.g. `server::jobs::canonicalization=debug`. |
+| `GUARDIAN_NETWORK_TYPE` (**required**) | Miden network name: `MidenLocal`, `MidenTestnet`, or `MidenDevnet`. The server refuses to start when it is unset or unrecognized. |
+
+At startup the server emits a warning that audit events will **not** be
+persisted — that's expected for filesystem mode
+([`builder/storage.rs:133`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs#L133)).
+
+The HTTP server binds on `:3000`, gRPC on `:50051`.
+
+## Path B — `cargo run` with Postgres
+
+```bash
+docker compose -f docker-compose.postgres.yml up -d
+
+DATABASE_URL=postgres://guardian:guardian@localhost:5432/guardian \
+ cargo run -p guardian-server --features postgres --bin server
+```
+
+The Postgres path runs SQL migrations on startup
+([`builder/storage.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs)) and
+wires `PostgresAuditor` so admin actions land in the `admin_actions` table.
+Pool sizing is controlled by `GUARDIAN_DB_POOL_MAX_SIZE` and
+`GUARDIAN_METADATA_DB_POOL_MAX_SIZE`.
+
+### Postgres-backed tests
+
+The `guardian-server` tests that need a live database (metadata replay state,
+delta storage fencing, the audit append-only trigger, and the lease, session,
+and challenge coordination stores) stay `#[ignore]` and run through one script:
+
+```bash
+POSTGRES_PASSWORD=guardian docker compose -f docker-compose.postgres.yml up -d postgres
+./scripts/test-postgres.sh
+```
+
+Name the `postgres` service explicitly: an unqualified `up -d` also builds and
+starts the server image, which these tests do not use. `POSTGRES_PASSWORD` is
+required even when only the database service is selected, because Compose
+interpolates the whole file; the script's default connection URL expects
+`guardian`. If your Compose volume was initialised with a different password,
+pass a matching `DATABASE_URL` instead of relying on the default.
+
+The suite drops and recreates the `public` schema before the first test and
+re-applies every migration from empty, so no run inherits state from an earlier
+one and there is no cleanup step. It creates `guardian_test` if it is missing,
+and it refuses to run against a database whose name does not end in `_test`, so
+a `DATABASE_URL` still exported from a Path B session cannot lose your
+development data. Point it elsewhere with:
+
+```bash
+DATABASE_URL=postgres://user:pass@host:5432/guardian_test ./scripts/test-postgres.sh
+```
+
+Run one suite at a time per server: the reset wipes the shared test database.
+Execution is serial because one test reverts the newest migration for the
+duration of its own run.
+
+The local compose Postgres uses no TLS, so omit `sslmode` (plaintext). To
+exercise certificate verification locally, run a TLS-enabled Postgres with a
+self-signed CA and point the server at it:
+
+```bash
+# Generate a CA and a server cert whose SAN matches the connection host
+openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem \
+ -subj "/CN=Test CA" -days 3650
+openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
+ -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"
+openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
+ -out server.crt -days 3650 -copy_extensions copyall
+# start postgres with ssl=on using server.crt/server.key, then:
+
+# verify-full: chain + hostname (SAN must be localhost)
+DATABASE_URL="postgres://guardian:guardian@localhost:5432/guardian?sslmode=verify-full&sslrootcert=$PWD/ca.pem" \
+ cargo run -p guardian-server --features postgres --bin server
+
+# verify-ca: chain only (hostname not checked)
+DATABASE_URL="postgres://guardian:guardian@localhost:5432/guardian?sslmode=verify-ca&sslrootcert=$PWD/ca.pem" \
+ cargo run -p guardian-server --features postgres --bin server
+```
+
+Expected: correct CA + matching SAN → starts; wrong/empty CA file → fails fast
+with a certificate error; under `verify-full` a SAN ≠ `localhost` is refused
+while the same cert is accepted under `verify-ca`. See the full matrix in
+[CONFIGURATION.md → Database TLS](../reference/configuration.md#database-tls).
+
+## Path C — `cargo run` with EVM support
+
+```bash
+GUARDIAN_EVM_RPC_URLS=31337=http://127.0.0.1:8545 \
+GUARDIAN_EVM_ENTRYPOINT_ADDRESS=0x... \
+ cargo run -p guardian-server --features evm --bin server
+```
+
+EVM routes (`/evm/auth/*`, `/evm/accounts`, `/evm/proposals*`) only register
+when the `evm` feature is on. Combine with `postgres` for prod-like local
+setups: `--features postgres,evm`. Pair with an Anvil node — the
+`smoke-test-evm-proposal-support` skill walks through the full flow.
+
+## Path D — Docker Compose
+
+```bash
+docker compose up --build -d
+docker compose logs -f
+```
+
+This is the default Compose flow — **filesystem backend**, no Postgres, no
+root `.env` required. For a Postgres-backed compose stack use
+`docker-compose.postgres.yml`. Endpoints are the same as Path A (`:3000`,
+`:50051`).
+
+## Choosing a feature flag combo
+
+| Goal | Features | Backend |
+|---|---|---|
+| Hack on a service handler quickly | _none_ | filesystem |
+| Touch migrations, audit, or multi-replica behavior | `postgres` | Postgres |
+| Exercise the EVM proposal flow | `evm` | filesystem |
+| Reproduce a prod issue locally | `postgres,evm` | Postgres |
+| Run the operator dashboard | _any_ | either (Postgres for durable history) |
+
+The deploy script builds with `postgres,evm` when the EVM stack is requested
+— see [`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#quick-start).
+
+## Verifying the server is up
+
+```bash
+curl http://localhost:3000/ # liveness (alias of /status)
+curl http://localhost:3000/pubkey # ACK key commitment
+grpcurl -plaintext \
+ -import-path crates/server/proto -proto guardian.proto \
+ -d '{}' localhost:50051 guardian.Guardian/GetPubkey
+```
+
+If `GetPubkey` returns a key, the server is wired correctly and the gRPC
+target group's health check
+([`infra/alb.tf:55`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/alb.tf#L55)) would pass in production.
+
+## Reaching for an example
+
+| Example | What it exercises | SDK |
+|---|---|---|
+| [`examples/demo`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/demo/README.md) | End-to-end multisig flow in a Rust TUI — recommended starting point. | Rust multisig client |
+| [`examples/rust`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/rust/README.md) | Low-level Rust binaries for both local-node and mockchain flows. | Rust client |
+| [`examples/smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/smoke-web/README.md) | Browser harness for multisig + wallet integrations. | TS multisig client |
+| [`examples/operator-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/operator-smoke-web/README.md) | Local Falcon operator login + dashboard account APIs. | `@openzeppelin/guardian-operator-client` |
+| [`examples/evm-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/evm-smoke-web/README.md) | EVM proposal lifecycle against Anvil + an EVM-enabled server. | `@openzeppelin/guardian-evm-client` |
+| [`examples/web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/web/README.md) | Reference web integration. | TS multisig client |
+
+Follow each example's README to drive it manually. Agents in this repo
+have matching skills (`smoke-test-rust-multisig-sdk`,
+`smoke-test-ts-multisig-sdk`, `smoke-test-operator-dashboard`,
+`smoke-test-evm-proposal-support`) that automate the same flows.
+
+## Running tests
+
+```bash
+cargo test --workspace
+cargo test -p guardian-server --features integration
+cargo test -p guardian-server --features e2e
+```
+
+TypeScript packages live in an npm workspace under `packages/`. Install
+once from that directory with `npm ci`, then run tests with
+`npm test -w @openzeppelin/`. `miden-multisig-client` depends
+on the in-repo `guardian-client` workspace package — build that first.
+See the root [`README.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/README.md#typescript-tests).
+
+Cargo feature gates (`integration`, `e2e`) document what each suite
+needs at the top of the relevant test modules under
+[`crates/server/src/testing`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/testing). Agents in
+this repo have a `guardian-validation-matrix` skill that picks the
+smallest meaningful set for a given change.
+
+## Common gotchas
+
+- **`DATABASE_URL` missing under `--features postgres`** — the builder
+ fails fast with `"DATABASE_URL environment variable is required"`
+ ([`builder/storage.rs:97`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/storage.rs#L97)).
+- **Filesystem dirs don't exist** — the server creates them on demand, but
+ the parent path must be writable.
+- **ACK keypair changes between runs in filesystem mode** — keys
+ auto-generate on first start. If clients pinned an old pubkey, point them
+ at the new one or persist the keystore directory.
+- **gRPC reflection over Cloudflare** — works locally but Cloudflare's
+ free tier rejects gRPC unless explicitly enabled on the zone.
+- **Apple Silicon + `--platform linux/amd64`** — slow but works. For
+ faster local Docker builds, build `linux/arm64` images and deploy with
+ `cpu_architecture = "ARM64"`.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/getting-started/quickstart.md b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/quickstart.md
new file mode 100644
index 00000000..4c21cb57
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/getting-started/quickstart.md
@@ -0,0 +1,78 @@
+---
+title: "Quickstart"
+sidebar_position: 2
+---
+A Guardian running on your machine in under 60 seconds, with one command
+to confirm it's alive.
+
+This is the fast path. For depth (Postgres, EVM, feature flags, examples)
+read [`docs/LOCAL_DEV.md`](./local-dev.md). For the *why* before the *how*,
+read [`docs/CONCEPTS.md`](./concepts.md). For production readiness, start
+with [`docs/PRODUCTION.md`](../operations/production.md).
+
+The compose file sets the container paths it needs, but you must choose a
+network explicitly — `GUARDIAN_NETWORK_TYPE` has no default and the server
+refuses to start without it (there is no fallback network). Set it in a
+local `.env` file or export it in your shell; accepted values are
+`MidenLocal`, `MidenTestnet`, and `MidenDevnet`. The same requirement
+applies when you run the server directly with `cargo run`; see
+[`LOCAL_DEV.md`](./local-dev.md#environment-file).
+
+## Run
+
+```bash
+echo "GUARDIAN_NETWORK_TYPE=MidenTestnet" > .env
+docker compose up --build -d
+```
+
+This launches the server with the filesystem backend. HTTP binds on
+`:3000`, gRPC on `:50051`.
+
+## Verify
+
+```bash
+curl http://localhost:3000/ # liveness — same body as /status, expect 200 OK
+curl http://localhost:3000/pubkey # expect { "commitment": "0x..." }
+```
+
+If both succeed, Guardian is running. The commitment you see is the ACK
+key commitment clients will pin.
+
+## Stop
+
+```bash
+docker compose down
+```
+
+## What you just got
+
+- A filesystem-backed Guardian server (no Postgres, no EVM).
+- Auto-generated ACK keypair persisted in the container's keystore.
+- HTTP + gRPC on default ports.
+- No operator dashboard (allowlist is empty by default).
+
+This is enough to point an example SDK at:
+
+```bash
+# Rust TUI demo against your local Guardian
+cd examples/demo && cargo run --release
+```
+
+The demo also needs a Miden RPC endpoint (Devnet works out of the box
+and runs the Miden 0.16 node this workspace targets). If you upgraded
+from an older checkout, wipe stale local state first (`store.sqlite3`,
+`~/.guardian`) — Miden 0.15 state does not load under 0.16 (see
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md)). See
+[`docs/LOCAL_DEV.md`](./local-dev.md#prerequisites) if your network
+choice differs.
+
+## Where to next
+
+| Goal | Read |
+|---|---|
+| Understand what Guardian *is* before going further | [`CONCEPTS.md`](./concepts.md) |
+| Switch to Postgres, enable EVM, run without Docker | [`LOCAL_DEV.md`](./local-dev.md) |
+| Set every available env var deliberately | [`CONFIGURATION.md`](../reference/configuration.md) |
+| Enable the operator dashboard locally | [`DASHBOARD.md`](../operations/dashboard.md) |
+| Deploy or operate in production | [`PRODUCTION.md`](../operations/production.md) |
+| Something broke | [`TROUBLESHOOTING.md`](../operations/troubleshooting.md) |
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/guides/_category_.json
new file mode 100644
index 00000000..c59a630d
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Guides",
+ "position": 3
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/aws-signers.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/aws-signers.md
new file mode 100644
index 00000000..87ad8858
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/aws-signers.md
@@ -0,0 +1,115 @@
+---
+title: "Self-hosted Docker Compose with AWS-managed ACK signers"
+sidebar_position: 2
+---
+Run the Guardian server with Docker Compose where:
+
+- **state** lives in Postgres (bundled in the Compose stack),
+- the **Falcon** ACK key lives in **AWS Secrets Manager**, and
+- the **ECDSA** ACK key lives in **AWS KMS** — its private key never enters the
+ server process.
+
+This is a self-hosted alternative to the ECS reference deployment
+([`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md)): you run the published
+image on your own host but use the same AWS-managed signer backends. It is
+distinct from [`LOCAL_DEV.md`](../getting-started/local-dev.md), which keeps keys on the
+filesystem.
+
+Every setting is in one place — [`.env.example`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/aws-signers/.env.example) and
+[`docker-compose.yml`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/aws-signers/docker-compose.yml) in this directory. For the
+authoritative meaning of any variable, see
+[`CONFIGURATION.md`](../reference/configuration.md).
+
+## Prerequisites
+
+- Docker and the AWS CLI, with credentials that can create a KMS key and a
+ Secrets Manager secret.
+- The repo checked out (for this Compose file and the `scripts/aws-deploy.sh`
+ bootstrap helpers).
+
+## 1. Create the KMS ECDSA key
+
+```bash
+STACK_NAME=guardian ./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key
+```
+
+This creates an `ECC_SECG_P256K1` / `SIGN_VERIFY` key plus an
+`alias/guardian-ack-ecdsa` alias and prints the key ARN. The alias is a stable
+handle you can use as `GUARDIAN_ACK_ECDSA_KMS_KEY_ID`.
+
+## 2. Create the Falcon secret in Secrets Manager
+
+```bash
+export TF_VAR_guardian_ack_ecdsa_kms_key_arn=""
+STACK_NAME=guardian ./scripts/aws-deploy.sh bootstrap-ack-keys
+```
+
+With the KMS ARN exported, `bootstrap-ack-keys` generates and stores **only the
+Falcon** secret (default name `guardian/server/ack-falcon-secret-key`) and skips
+ECDSA, since ECDSA is KMS-backed. See
+[`runbooks/secrets.md`](../runbooks/secrets.md#hosted-ecdsa-backend-aws-kms)
+for key lifecycle and the immutable-spec caveat.
+
+## 3. Configure the environment
+
+From this directory:
+
+```bash
+cp .env.example .env
+```
+
+Set in `.env`:
+
+- `POSTGRES_PASSWORD` — a strong, stable, URL-safe value.
+- `AWS_REGION` — the region holding the secret and key.
+- `GUARDIAN_ACK_FALCON_SECRET_ID` — the Falcon secret name from step 2.
+- `GUARDIAN_ACK_ECDSA_KMS_KEY_ID` — the alias or ARN from step 1.
+
+The Compose file already pins `GUARDIAN_ENV=prod` (the switch that makes the
+server load ACK keys from Secrets Manager) and `GUARDIAN_ACK_ECDSA_BACKEND=aws-kms`.
+
+### AWS credentials for the container
+
+`GUARDIAN_ENV=prod` means the container calls Secrets Manager and KMS, so it
+needs credentials. The Compose file passes them through from your shell:
+
+```bash
+export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=...
+```
+
+For a long-lived host, prefer an EC2 instance role or container credentials over
+static keys (leave the `AWS_*` keys unset and the SDK picks up the role).
+
+## 4. Run
+
+From this directory (Compose auto-discovers `docker-compose.yml` and `.env`):
+
+```bash
+docker compose up
+```
+
+At startup the server imports the Falcon key into its keystore and runs a KMS
+sign probe to confirm `kms:Sign` works — it fails fast on a misconfigured key or
+missing permission rather than at first acknowledgement. The logs include
+`ECDSA ACK signer ready` with the active backend.
+
+## 5. Validate
+
+```bash
+curl -s localhost:3000/pubkey | jq .
+```
+
+You should see the Falcon and ECDSA public keys and commitments. The ECDSA
+commitment is derived from the KMS key; record it — moving to a different KMS
+key later is a Guardian identity change (a `SwitchGuardian` migration for
+existing accounts), not a routine rotation.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| `configuration_error` mentioning the sign probe at startup | KMS key wrong spec (must be `ECC_SECG_P256K1` / `SIGN_VERIFY`), or the task lacks `kms:Sign` |
+| Startup fails resolving the Falcon secret | `GUARDIAN_ACK_FALCON_SECRET_ID` wrong, or credentials can't read it / wrong `AWS_REGION` |
+| `AWS_REGION` errors at boot | `GUARDIAN_ENV=prod` requires `AWS_REGION` to be set |
+
+See [`TROUBLESHOOTING.md`](../operations/troubleshooting.md) for the full error-code playbook.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/horizontal-scaling.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/horizontal-scaling.md
new file mode 100644
index 00000000..52e474c6
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/horizontal-scaling.md
@@ -0,0 +1,317 @@
+---
+title: "Horizontal scaling: two replicas behind a proxy"
+---
+Run two Guardian replicas behind a round-robin proxy, sharing one Postgres, and
+watch the coordination layer (issue #242) work end to end on your laptop. This
+mirrors the prod topology — 2–6 ECS tasks behind a load balancer — in miniature.
+
+```text
+ :8080 (HTTP) ┌─────────────┐ server-a :3000 (HTTP)
+ client ──▶ │ proxy/Caddy │ ──round-robin──┬──▶ :50052 (gRPC)
+ :50051 (gRPC) └─────────────┘ └──▶ server-b :3010 (HTTP)
+ :50053 (gRPC)
+ │ │
+ └────┬────┘
+ ▼
+ postgres :5432
+ (sessions · challenges · worker lease)
+```
+
+Everything that must be shared for a correct multi-replica deployment lives in
+Postgres, so a session minted on one replica is honored on the other, and only
+one replica ever canonicalizes. The variable meanings live in
+[`../../CONFIGURATION.md`](../reference/configuration.md); the operational contract is in
+[`../../runbooks/horizontal-scaling.md`](../runbooks/horizontal-scaling.md).
+
+## Prerequisites
+
+- Docker with Compose v2 (`docker compose`).
+- The Rust toolchain and `jq`, used once to generate the shared ACK signing
+ keys (next section).
+- No AWS anywhere. The replicas share one Guardian identity through two local
+ key files (`GUARDIAN_ACK_SECRET_PROVIDER=file`) — the non-AWS stable identity
+ from [issue #289](https://github.com/OpenZeppelin/guardian/issues/289).
+
+## Configure and run
+
+```sh
+cp .env.example .env
+# Set a real cursor secret — it MUST be identical on every replica:
+# openssl rand -hex 32 → paste into GUARDIAN_DASHBOARD_CURSOR_SECRET
+cp operators.example.json operators.json # empty allowlist `[]`; add a key for the login walkthrough (step 8)
+```
+
+Generate **one** ACK keypair that both replicas mount (this is the fleet's
+Guardian identity — see [What is shared](#what-is-shared-and-why)). The files
+must be owner-only or the server refuses to start:
+
+```sh
+mkdir -p ack-keys
+cargo run --quiet -p guardian-server --bin ack-keygen \
+ | { read -r json; \
+ jq -rj '.falcon_secret_key' <<<"$json" > ack-keys/ack-falcon-secret-key; \
+ jq -rj '.ecdsa_secret_key' <<<"$json" > ack-keys/ack-ecdsa-secret-key; }
+chmod 600 ack-keys/ack-falcon-secret-key ack-keys/ack-ecdsa-secret-key
+```
+
+`ack-keys/` is git-ignored. Treat it like any private key material — and note
+that regenerating it changes the Guardian's identity, freezing any multisig
+account that pinned the old one (see the
+[secrets runbook](../runbooks/secrets.md#self-hosted-stable-identity-without-aws)).
+
+> **Building an unreleased version?** The published `latest` image does not yet
+> contain these coordination changes (issue #242). Drop in the local-build
+> override so the replicas build from the repo-root `Dockerfile` instead of
+> pulling from the registry — Compose auto-merges it, so no extra flags are
+> needed on any command:
+>
+> ```sh
+> cp docker-compose.override.yml.example docker-compose.override.yml
+> ```
+>
+> Once the change ships in a published image, delete
+> `docker-compose.override.yml` to go back to the registry image.
+
+```sh
+docker compose up -d --build
+```
+
+The proxy is at [http://localhost:8080](http://localhost:8080). Each replica is also exposed directly —
+`server-a` on `:3000`, `server-b` on `:3010` — so you can target a specific
+replica during the walkthrough. Postgres is on `:5432`.
+
+gRPC mirrors the HTTP layout: the proxy round-robins plaintext gRPC (h2c) on
+`:50051` — which is the Rust demo CLI's default endpoint, so its `[1] Local
+gRPC` choice goes through the load balancer — with the replicas reachable
+directly on `:50052` (A) and `:50053` (B).
+
+## What is shared (and why)
+
+| Shared in Postgres | Table | Effect across replicas |
+|---|---|---|
+| Operator/EVM sessions | `auth_sessions` | Log in on A, your cookie works on B; logout is honored fleet-wide. |
+| Login challenges | `auth_challenges` | A challenge is single-use even if issued on A and verified on B. |
+| Canonicalization lease | `worker_leases` | Exactly one replica promotes candidates; the others stand by. |
+| Replay protection | `account_auth_state` | A request timestamp accepted on A cannot be replayed to B; each per-account-and-signer timestamp is usable exactly once fleet-wide. A retained migration floor also protects signers first authorized after an account-scoped upgrade. |
+
+Coordination is **backend-derived**: it is on because the backend is Postgres.
+No environment variable enables or disables it.
+
+**Upgrading across schema migrations**: migrations run automatically at
+startup, and the first replica to boot a new binary migrates the shared
+database for the whole fleet. Migrations that move authentication state lock
+the affected table for their transaction, so replay timestamps accepted by
+old replicas are never lost mid-migration — old-binary writes either land
+before the migration's snapshot or fail on the schema change. A replica still
+running the previous binary therefore fails closed — its queries name columns
+the migration removed, so it serves errors instead of authenticating against
+stale state — until it is replaced. Plan rolling deploys accordingly: old
+replicas may error (never misbehave) during the window between the first
+new-binary boot and the last replica replacement.
+
+One shared thing lives **outside** Postgres: the ACK signing keys. Both
+replicas mount the same `./ack-keys` files and load them via
+`GUARDIAN_ACK_SECRET_PROVIDER=file`, so the fleet presents a single,
+restart-stable Guardian identity at `/pubkey`. That matters for multisig: each
+account pins the guardian's `/pubkey` commitment into its
+`openzeppelin::guardian::public_key` slot at configure time, and a replica
+whose identity doesn't match rejects co-signing with `invalid GUARDIAN public
+key binding`. With per-replica ephemeral keys (the non-prod default), routing
+multisig through the proxy would fail on every request the "wrong" replica
+answered.
+
+## Validation walkthrough
+
+### 1. Both replicas report shared coordination
+
+```sh
+docker compose logs server-a server-b | grep -i coordination
+```
+
+Each replica prints one line; both must read `mode=shared backend=postgres`. If
+you ever see `mode=single-process backend=filesystem`, that replica is **not**
+safe to run alongside others.
+
+### 2. One Guardian identity across the fleet
+
+```sh
+diff <(curl -s http://localhost:3000/pubkey) <(curl -s http://localhost:3010/pubkey) \
+ && echo "identical"
+```
+
+Both replicas serve a byte-identical `/pubkey` response because they load the
+same `./ack-keys` files, and the identity survives restarts: `docker compose
+restart server-a` and run the diff again — still identical. (Remove the
+`GUARDIAN_ACK_*` variables from `docker-compose.yml` and each replica mints its
+own ephemeral key per boot; the diff then fails and multisig through the proxy
+breaks — see [What is shared](#what-is-shared-and-why).)
+
+### 3. Exactly one canonicalization lease holder
+
+```sh
+docker compose exec postgres \
+ psql -U guardian -d guardian \
+ -c "select lease_name, holder_id, fence_token from worker_leases;"
+```
+
+You get a single `canonicalization` row with one `holder_id` (formatted
+`{pid}-{random}`) — never two. Both replicas run the worker loop, but only the
+lease holder does work; the other keeps trying to acquire and backs off.
+
+### 4. Lease failover with a fencing-token bump
+
+Stop the current holder and watch a different replica take over within the lease
+TTL (~30s, i.e. 3× the 10s canonicalization interval):
+
+```sh
+docker compose stop server-a # if A wasn't the holder, stop server-b instead
+watch -n2 'docker compose exec -T postgres \
+ psql -U guardian -d guardian \
+ -c "select holder_id, fence_token, expires_at from worker_leases;"'
+```
+
+`holder_id` changes to the surviving replica and `fence_token` **increments** —
+the increment is the steal signal a superseded holder uses to fence itself off
+at its next write. Bring the replica back with `docker compose start server-a`;
+the lease does not bounce back (the current holder keeps renewing).
+
+### 5. Proxy request failover
+
+The lease failover above is server-side; the proxy also has to stop routing
+*client* requests to a dead replica. That is what the `health_uri` / `lb_*`
+directives in the [`Caddyfile`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/horizontal-scaling/Caddyfile) do — a bare `round_robin` (no health
+checks) keeps sending half the traffic to the dead replica and returns `502`.
+Kill a replica and hit the proxy:
+
+```sh
+docker compose stop server-b
+for i in $(seq 1 4); do curl -s -o /dev/null -w "%{http_code} " \
+ http://localhost:8080/pubkey; done; echo
+```
+
+Every response stays `200` — Caddy health-checks each replica and routes only to
+the survivor. Bring it back with `docker compose start server-b`; Caddy re-adds
+it within one health interval (~5s). (Strip the health directives from the
+`Caddyfile` and the same loop returns alternating `502`s.)
+
+### 6. Auth fails closed when the shared store is down
+
+Pause Postgres and watch the holder step down rather than barrel ahead:
+
+```sh
+docker compose pause postgres
+docker compose logs -f server-a server-b # Ctrl-C after a few seconds
+```
+
+You will see lease renew/acquire failures and storage errors — the worker
+**cancels its pass** instead of canonicalizing blind. If you have completed the
+login walkthrough below, an authenticated request fails rather than silently
+succeeding: authentication is **fail-closed**.
+
+> `docker compose pause` freezes Postgres mid-connection (SIGSTOP), so an
+> in-flight request *hangs until it times out* rather than getting a prompt
+> `5xx`. Either way it never succeeds. To see a fast `5xx` instead (socket
+> closed → connection refused), use `docker compose stop postgres` and
+> `docker compose start postgres` to recover.
+
+Recover with:
+
+```sh
+docker compose unpause postgres
+```
+
+Coordination resumes automatically; no manual intervention.
+
+### 7. Rate-limit partitioning and `X-Forwarded-For`
+
+Each replica enforces `global / GUARDIAN_MAX_REPLICAS`. With the default global
+burst of 10 and `GUARDIAN_MAX_REPLICAS=2`, a single replica caps at ~5 req/s.
+Hammer one replica directly (the challenge endpoint is unauthenticated and
+rate-limited):
+
+```sh
+for i in $(seq 1 12); do
+ curl -s -o /dev/null -w "%{http_code} " \
+ "http://localhost:3000/auth/challenge?commitment=0xdemo"
+done; echo
+```
+
+After the per-replica burst is spent you see `429`s. Through the proxy
+(`:8080`), Caddy sets `X-Forwarded-For`, so the server keys the limit on your
+real client IP rather than the proxy address — confirm by repeating the loop
+against `http://localhost:8080/...` and seeing the same per-IP behavior.
+
+### 8. (End-to-end) An operator session survives losing its replica
+
+This is the headline, and it needs a real operator key to sign the challenge.
+Use the [`examples/operator-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/operator-smoke-web)
+harness (or the operator client) pointed at the **proxy** URL
+`http://localhost:8080`:
+
+1. Generate a Falcon operator key with the harness and add its public key to
+ `operators.json` (replacing the empty `[]`); the allowlist hot-reloads, so no
+ restart is needed:
+
+ ```json
+ [{ "public_key": "0x", "permissions": ["dashboard:read"] }]
+ ```
+2. Complete the login (`GET /auth/challenge` → sign → `POST
+ /auth/verify`). The proxy round-robins, so this may land on either
+ replica; the session row is written to `auth_sessions`.
+3. Make an authenticated request (e.g. `GET /dashboard/accounts`) a few times —
+ each may be served by a different replica, and all succeed: the cookie is
+ validated against the shared store, not per-process memory.
+4. Now `docker compose stop` the replica that handled your login and repeat —
+ **your session still works** on the survivor. Then `POST
+ /auth/logout`; the revocation is honored on every replica.
+
+## Cleanup
+
+```sh
+docker compose down -v # -v also drops the Postgres + keystore volumes
+```
+
+`./ack-keys` is a host directory, so `down -v` leaves it alone — keep it and the
+Guardian identity survives a full teardown. Delete it only to discard that
+identity for good (any account configured against it would need a
+`SwitchGuardian` migration).
+
+## From this demo to production
+
+This guide stays AWS-free to be runnable; a real prod deployment differs in two
+ways that do not change the coordination behavior shown above:
+
+- **`GUARDIAN_ENV=prod`** activates the prod-stage startup guards — a filesystem
+ storage backend and a rate limit that partitions to 0 req/replica are each
+ refused at startup. (An unset `GUARDIAN_DASHBOARD_CURSOR_SECRET` only *warns* —
+ it degrades cross-replica pagination on the dashboard feeds and the client `/delta/history` endpoint, not custody, so a
+ single-replica prod server still boots.) Note these guards live behind the ACK
+ registry init, which in prod requires AWS first: set `GUARDIAN_ENV=prod`
+ without `AWS_REGION` and the server refuses to start with `AWS_REGION is
+ required when GUARDIAN_ENV=prod` before it ever reaches the storage or
+ rate-limit checks — so observing those two specifically needs AWS configured.
+- **Where the shared ACK key lives.** Both setups give the fleet one guardian
+ identity — the property multisig needs, since each account pins the
+ guardian's `/pubkey` commitment at configure time. This guide sources it from
+ two local files (`GUARDIAN_ACK_SECRET_PROVIDER=file`, the `./ack-keys` bind
+ mount); prod sources the same key material from AWS Secrets Manager
+ (`GUARDIAN_ENV=prod` defaults the provider to `aws`), so no secret sits on a
+ task's disk. The formats are identical — the hex strings `ack-keygen` emits —
+ so keys are portable between the two providers; see the
+ [aws-signers guide](./aws-signers.md) and the
+ [secrets runbook](../runbooks/secrets.md#ack-signing-keys). Per-account
+ state already lives in Postgres and needs nothing extra.
+
+> **Smoke-testing multisig against this demo?** The shared `file`-provider
+> identity makes the round-robin proxy safe for the full multisig flow —
+> configure, co-sign, and execute can each land on a different replica. Point
+> HTTP clients at `:8080` and gRPC clients (the Rust demo CLI) at `:50051`,
+> its default. Just make the client's Miden RPC network match the server's
+> `GUARDIAN_NETWORK_TYPE` (e.g. devnet RPC ↔ `MidenDevnet`), or
+> canonicalization will loop on an `on_chain=0x00…0` commitment because the
+> account was deployed to a different network than the guardian verifies
+> against.
+
+The managed path (published Postgres image + the prod Terraform profile) sets
+all of this for you; see [`../../SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md)
+and the [horizontal-scaling runbook](../runbooks/horizontal-scaling.md).
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/miden-dashboard.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/miden-dashboard.md
new file mode 100644
index 00000000..990b98e8
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/miden-dashboard.md
@@ -0,0 +1,151 @@
+---
+title: "Run the Miden Dashboard UI with Guardian"
+sidebar_position: 3
+---
+Stand up a local Guardian server and the operator dashboard UI
+([`0xMiden/guardian-dashboard`](https://github.com/0xMiden/guardian-dashboard))
+side by side with Docker Compose, then log in as an operator and browse
+accounts end to end.
+
+This is the assembly layer that ties together the existing reference docs — it
+does not re-explain them. For the dashboard's trust model, the
+challenge→session auth flow, the allowlist format, and the permission
+vocabulary, see [`DASHBOARD.md`](../operations/dashboard.md). For the authoritative
+meaning of any server variable, see [`CONFIGURATION.md`](../reference/configuration.md).
+
+The stack bundles **Postgres** for Guardian's state and metadata — the published
+Guardian image is built with the `postgres` feature, so a database is required
+(this guide is a realistic self-hosted base, not a throwaway filesystem toy). The
+ACK signer uses an in-memory key held in a local keystore — fine for a single
+host; production deployments move it to AWS Secrets Manager / KMS (see the
+[AWS-managed signers guide](./aws-signers.md)). The dashboard is a
+Next.js app the upstream repo ships without an image, so Compose runs it from a
+source clone on the stock `node` image.
+
+How the pieces talk: the browser only talks to the dashboard; the dashboard's
+**Next.js backend** holds the operator's Falcon private key and signs the
+challenge→session flow against the Guardian server itself. The server is
+reached over the Compose network at `http://server:3000`, so it needs **no CORS
+configuration** here.
+
+## Prerequisites
+
+- Docker.
+- A clone of the dashboard inside this guide directory. From here:
+ ```bash
+ git clone https://github.com/0xMiden/guardian-dashboard
+ ```
+ By default this guide expects it at `./guardian-dashboard`. Point elsewhere
+ with `GUARDIAN_DASHBOARD_PATH` in `.env`.
+- A free [Clerk](https://dashboard.clerk.com) application — the dashboard uses
+ Clerk for human sign-in and cannot start without its keys.
+
+## 1. Generate an operator key
+
+The dashboard ships a helper that emits everything you need — the **private
+key** and **commitment** for the dashboard, and the **public key** for the
+Guardian allowlist. From your `guardian-dashboard` clone:
+
+```bash
+npm install
+npx tsx scripts/generate-operator-key.ts
+```
+
+It prints two labelled blocks: a `GUARDIAN_OPERATOR_PRIVATE_KEY` /
+`GUARDIAN_OPERATOR_COMMITMENT` pair, and a `["0x…"]` public key. These are not
+env vars you set directly — in step 3 the private key and commitment go into the
+`privateKey` and `commitment` fields of the `GUARDIAN_ENDPOINTS` entry in `.env`,
+and the public key goes into `operators.json`. Keep the private key secret.
+(Falcon keys generated by the multisig SDK or the
+[`operator-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/operator-smoke-web) UI work too, as
+long as you can export all three values.)
+
+## 2. Configure Clerk
+
+In your Clerk app, copy the **test** publishable and secret keys for step 3.
+Then create the admin user (or use your own) and set its **public metadata**
+so the dashboard authorises it for this node:
+
+```json
+{ "role": "admin", "endpointIds": ["local"] }
+```
+
+`endpointIds` must include the endpoint `id` you use in `GUARDIAN_ENDPOINTS`
+below (`local` here). Without it the dashboard signs you in but shows no nodes.
+
+## 3. Configure the environment
+
+From this directory:
+
+```bash
+cp .env.example .env
+cp operators.example.json operators.json
+```
+
+In `.env` set:
+
+- `POSTGRES_PASSWORD` — a strong, stable, URL-safe value for the bundled Postgres.
+- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` — the test keys
+ from step 2.
+- `GUARDIAN_ENDPOINTS` — fill `commitment` and `privateKey` from step 1. Leave
+ `url` as `http://server:3000` (the server's address on the Compose network)
+ and keep `network` equal to `GUARDIAN_NETWORK_TYPE`.
+
+In `operators.json` replace `0x` with the public
+key from step 1. The default entry grants `dashboard:read` and `accounts:pause`;
+trim the permission set if you only need read access (see the permission
+vocabulary in [`DASHBOARD.md`](../operations/dashboard.md#permission-vocabulary)). The server
+hot-reloads this file on every challenge and authenticated request, so you can
+edit operators without restarting.
+
+## 4. Run
+
+From this directory (Compose auto-discovers `docker-compose.yml` and `.env`):
+
+```bash
+docker compose up
+```
+
+The first dashboard boot runs `npm install` inside the container and is slow;
+subsequent boots reuse the cached `node_modules` volume. The server starts
+first; the dashboard only calls it once you sign in, by which point it is ready.
+
+## 5. Validate
+
+Confirm the server is live and serving its ACK keys:
+
+```bash
+curl -s localhost:3000/pubkey | jq .
+```
+
+Then open the dashboard at [http://localhost:3001](http://localhost:3001), sign in through Clerk,
+select the **Local Guardian** endpoint, and confirm the account list loads. A
+successful list proves the full path: Clerk sign-in → operator
+challenge→session against the server → an authenticated `/dashboard/accounts`
+call.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| Sign-in works but no endpoint is selectable | Clerk public metadata missing `endpointIds`, or it omits the `id` from `GUARDIAN_ENDPOINTS` |
+| Endpoint selected but accounts list is empty or 401s | Operator public key not in `operators.json`, or `network` in `GUARDIAN_ENDPOINTS` ≠ `GUARDIAN_NETWORK_TYPE` |
+| Dashboard fails to start citing Clerk keys | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` / `CLERK_SECRET_KEY` unset in `.env` |
+| Dashboard container exits / `/app` is empty | `GUARDIAN_DASHBOARD_PATH` doesn't point at your `guardian-dashboard` clone |
+| First `docker compose up` hangs on the dashboard | `npm install` running on first boot — wait it out; later boots are fast |
+| Port 3001 already in use | Stop the conflicting process, or remap the dashboard port in `docker-compose.yml` |
+
+See [`TROUBLESHOOTING.md`](../operations/troubleshooting.md) for the full server
+error-code playbook.
+
+## Operations checklist
+
+When standing up the dashboard against a stack:
+
+- [ ] At least one operator with `dashboard:read` in the allowlist — otherwise
+ the dashboard is unreachable.
+- [ ] `network` in `GUARDIAN_ENDPOINTS` matches `GUARDIAN_NETWORK_TYPE`.
+- [ ] Clerk operator metadata `endpointIds` includes every endpoint `id` the
+ user should see.
+- [ ] A fresh sign-in → endpoint select → account list round trip succeeds
+ before considering the stack live.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/observability.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/observability.md
new file mode 100644
index 00000000..0d676dbe
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/observability.md
@@ -0,0 +1,143 @@
+---
+title: "Observability with Prometheus + Grafana"
+sidebar_position: 4
+---
+Run a Guardian server with Prometheus metrics enabled, scraped by Prometheus
+and visualized in a pre-provisioned **Guardian — Server Overview** Grafana
+dashboard — all from one `docker compose up`. Use it to see Guardian's metric
+surface, or as a starting point for your own dashboards.
+
+This is a local, single-host stack (filesystem storage, anonymous Grafana). It
+is distinct from a production setup, where you would scrape your real server
+from an existing Prometheus and protect the endpoint as below. Every setting is
+in [`.env.example`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/observability/.env.example) and [`docker-compose.yml`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/observability/docker-compose.yml)
+in this directory; for the authoritative meaning of any variable, see
+[`CONFIGURATION.md`](../reference/configuration.md) (“Runtime — metrics”).
+
+## What you get
+
+The stack runs three services on the internal Compose network:
+
+- **server** — Guardian with `GUARDIAN_METRICS_ENABLED=true`, serving the
+ Prometheus exposition on a dedicated listener (`:9464`, not published to the
+ host).
+- **prometheus** — scrapes `server:9464` every 15s with a bearer token.
+- **grafana** — auto-provisioned with the Prometheus datasource and the
+ dashboard, anonymous access for zero-friction local viewing.
+
+The server instruments, end to end: HTTP and gRPC request paths (rate, latency,
+in-flight, status/code), Miden RPC (the upstream chain node), storage
+operations and per-pool (`storage`/`metadata`) DB-pool saturation,
+canonicalization, the delta & proposal lifecycle, account growth, operator auth,
+rate limiting, refresher health, and process metrics.
+
+Two properties worth knowing as an operator:
+
+- **Scrapes are cheap.** Expensive cross-account aggregates (delta counts,
+ in-flight proposals, account totals, pool status) are computed by a background
+ refresher every `GUARDIAN_METRICS_REFRESH_INTERVAL_SECS` and published as
+ gauges — a scrape never touches the database. Staleness is observable as
+ `time() - guardian_metrics_refresh_timestamp_seconds`.
+- **Cardinality is bounded by construction.** Every label value comes from a
+ closed set (route templates, a gRPC method allowlist, small enums). No account
+ IDs, nonces, keys, IPs, or error strings become labels.
+
+## Prerequisites
+
+- Docker (with Compose) and outbound internet — the server connects to its Miden
+ network's RPC at startup.
+
+## 1. Start the stack
+
+```bash
+cd docs/guides/observability
+cp .env.example .env # optional — defaults work as-is
+docker compose up
+```
+
+The first run builds the Guardian server image from this repo — several minutes
+on a cold build, since it compiles the server in release mode (it must include
+the current metrics code, so it can't use the published Postgres-only image).
+Later runs reuse the cached image. Prometheus and Grafana are pulled as images.
+
+## 2. Open the dashboard
+
+- **Grafana** → [http://localhost:3001](http://localhost:3001) — anonymous access is enabled, so you
+ land directly on **Dashboards → Guardian → Guardian — Server Overview**.
+- **Prometheus** → [http://localhost:9090](http://localhost:9090) — check **Status → Targets**: the
+ `guardian` target should be `UP`.
+
+## 3. Generate traffic
+
+The panels move once the server sees requests:
+
+```bash
+curl -s http://127.0.0.1:3000/pubkey >/dev/null
+curl -s "http://127.0.0.1:3000/auth/challenge?commitment=0xdeadbeef" >/dev/null
+```
+
+## 4. Tear down
+
+```bash
+docker compose down # add -v to also drop the volumes
+```
+
+## The dashboard
+
+Panels are grouped by subsystem and map 1:1 onto the metric taxonomy in
+[`spec/api.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/api.md) (“Metrics Endpoint”):
+
+| Section | Covers |
+|---|---|
+| Overview | build info, account count, in-flight proposals, refresh staleness, HTTP rate & error % |
+| HTTP / gRPC request path | rate by route/method, status/code breakdown, p50/p95/p99 latency, in-flight |
+| Miden RPC | upstream chain-node call rate, errors, p95 latency |
+| Storage & DB pools | operation rate/latency, per-pool (`storage`/`metadata`) connection saturation |
+| Canonicalization | run rate & duration, candidate outcomes, retries |
+| Delta & proposal lifecycle | submissions, proposal events, deltas-by-status, in-flight |
+| Accounts | total + creation rate by network kind |
+| Auth & rate limiting | operator auth outcomes, sessions, rate-limit rejections, refresh failures |
+| Process / runtime | CPU, RSS, file descriptors |
+
+An `Instance` variable (top-left) filters to a single replica or aggregates
+across all of them. Some panels stay empty in this local filesystem stack —
+**DB pool** needs a Postgres build, and **Miden RPC / deltas / proposals /
+account creation** need a real signed multisig flow. Copy
+[`grafana/dashboards/guardian.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/observability/grafana/dashboards/guardian.json) into
+your own Grafana and adapt it.
+
+## Protecting the endpoint (production)
+
+This stack keeps the metrics listener on the internal network and gates it with
+a throwaway `devtoken`. In production, defense is layered (see
+[`spec/api.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/api.md) and the
+[security note in Configuration](../reference/configuration.md#runtime--metrics-prometheus)):
+
+1. **Network isolation first** — the listener binds loopback by default; keep
+ `9464` reachable only from the scraper's network (private subnet / security
+ group / sidecar).
+2. **Bearer token second** — `GUARDIAN_METRICS_BEARER_TOKEN` gates scrapes with
+ a constant-time check (`401` otherwise). Point Prometheus at a mounted secret
+ with `authorization.credentials_file:` rather than an inline value.
+3. **TLS** — terminate at a reverse proxy or sidecar where transport encryption
+ is required.
+
+Never expose `/metrics`, Prometheus, or this Grafana to a public network — the
+ports here are bound to `127.0.0.1` and Grafana runs anonymous-admin precisely
+because it is local-only.
+
+## AWS deployment
+
+The Terraform reference deployment ships this metric surface to **CloudWatch**
+instead of Prometheus/Grafana: an ADOT Collector sidecar in the ECS task scrapes
+the loopback-only endpoint and exports selected metrics via EMF, with a
+Terraform-managed dashboard and alarms. See
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#metrics-dashboard-and-alarms).
+
+## Out of scope
+
+Alert and recording rules and runbooks are not shipped here. The
+[metric taxonomy in `spec/api.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/api.md) is the reference for
+writing your own — e.g. alert on `guardian_db_pool_pending_acquires` sustained
+above zero, on `time() - guardian_metrics_refresh_timestamp_seconds` exceeding a
+few refresh intervals, or on gRPC/HTTP error ratios.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/overview.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/overview.md
new file mode 100644
index 00000000..910483c6
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/overview.md
@@ -0,0 +1,36 @@
+---
+title: "Guides"
+sidebar_position: 1
+---
+Task-oriented, end-to-end walkthroughs for running Guardian in a specific mode.
+Each guide assembles a complete, copy-pasteable configuration in one place and
+links to [`CONFIGURATION.md`](../reference/configuration.md) for the authoritative meaning
+of each variable.
+
+These differ from the other docs by intent:
+
+- **Guides** (here) — "how do I run it set up like *this*?" end to end.
+- [`CONFIGURATION.md`](../reference/configuration.md) — flat reference for every env var.
+- [`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md) — the ECS/Terraform deploy procedure.
+- [`runbooks/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/runbooks/) — operational procedures (secrets, incidents).
+
+Guides use Docker Compose unless a guide says otherwise, so directory names
+describe the *configuration* a guide demonstrates rather than repeating the
+runner. Name a guide after what makes it distinct (its signer backends,
+storage, or network), not after Compose.
+
+## Available guides
+
+| Guide | Mode |
+|---|---|
+| [AWS-managed ACK signers](./aws-signers.md) | Self-hosted Compose: Postgres + Secrets Manager (Falcon) + KMS (ECDSA) |
+| [Miden Dashboard UI](./miden-dashboard.md) | Self-hosted Compose: Postgres + Guardian server + the Miden Dashboard operator UI |
+| [Observability](./observability.md) | Local Compose: server + Prometheus + pre-provisioned Grafana dashboard |
+| [Horizontal scaling](./horizontal-scaling.md) | Local Compose: two replicas + round-robin proxy + shared Postgres (sessions, lease failover, fail-closed auth) |
+
+## Adding a guide
+
+Give each guide its own subdirectory holding a `README.md` and its committed,
+runnable artifacts (e.g. `docker-compose.yml` + `.env.example`), so the guide
+and the config you copy live together and the config can be smoke-tested. Keep
+variable explanations in `CONFIGURATION.md` rather than restating them here.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/guides/postgres-tls.md b/versioned_docs/version-0.16/builder/miden-guardian/guides/postgres-tls.md
new file mode 100644
index 00000000..42529137
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/guides/postgres-tls.md
@@ -0,0 +1,133 @@
+---
+title: "Verified database TLS with Docker Compose"
+sidebar_position: 5
+---
+Run the Guardian server against a **TLS-enabled Postgres** and have the server
+**verify the database certificate** — not just encrypt the connection. The
+bundled Postgres terminates TLS with a self-signed certificate; the server
+connects with `sslmode=verify-full` and validates that certificate against a CA
+you generate.
+
+This guide is the local, self-contained way to see Guardian's database TLS
+verification accept a good certificate and reject a bad one. It complements
+[`LOCAL_DEV.md`](../getting-started/local-dev.md) (which uses a plaintext Postgres) and the
+AWS reference deployment in
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md) (which verifies the RDS
+certificate the same way). For the authoritative meaning of `sslmode` /
+`sslrootcert`, see [Database TLS](../reference/configuration.md#database-tls).
+
+Everything is in this directory: [`docker-compose.yml`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/postgres-tls/docker-compose.yml),
+[`.env.example`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/postgres-tls/.env.example), and [`generate-certs.sh`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/guides/postgres-tls/generate-certs.sh).
+
+> **Image version:** verified database TLS ships in the server image; run a
+> `GUARDIAN_VERSION` that includes it. Older images don't parse `verify-full` /
+> `sslrootcert` and fail to connect (or connect without verifying), so this guide
+> only works on an image that includes the feature. Before it's released, build
+> one locally — see [Run against a local build](#run-against-a-local-build).
+
+## How it fits together
+
+- `generate-certs.sh` creates a throwaway CA and a Postgres server cert whose
+ **SAN is `postgres`** — the Compose service name the server dials — so
+ `verify-full` hostname matching succeeds.
+- A one-shot `db-cert-init` service fixes the key's owner/permissions into a
+ shared volume (Postgres rejects a world-readable or wrong-owner key), then
+ exits; Postgres starts only after it succeeds. This mirrors the init-container
+ pattern the ECS deployment uses to deliver the CA bundle.
+- The server mounts only `ca.pem` (the public trust anchor) and connects with
+ `sslmode=verify-full&sslrootcert=/etc/guardian/tls/ca.pem`.
+
+## Prerequisites
+
+- Docker (with Compose) and OpenSSL.
+- The repo checked out (for this Compose file and the cert script).
+
+## 1. Generate the certificates
+
+```bash
+./generate-certs.sh
+```
+
+Writes `certs/ca.pem`, `certs/server.crt`, `certs/server.key` (gitignored).
+
+## 2. Configure the environment
+
+```bash
+cp .env.example .env
+```
+
+Set `POSTGRES_PASSWORD` to a strong, URL-safe value. Optionally pin
+`GUARDIAN_VERSION` and set `DB_SSLMODE` (default `verify-full`).
+
+## 3. Run
+
+```bash
+docker compose up
+```
+
+Order is enforced by Compose: `db-cert-init` writes the key → Postgres starts
+with `ssl=on` and becomes healthy → the server starts, runs migrations, and
+opens its connection pool. The server verifies the Postgres certificate on both
+the migration (libpq) and pool (rustls) connections.
+
+## 4. Validate
+
+A clean startup (no certificate error in the logs, migrations applied, pool
+ready) means verification passed. Confirm the server is up:
+
+```bash
+curl -s localhost:3000/pubkey | jq .
+```
+
+## 5. Experiment with the verification levels
+
+Stop the stack (`docker compose down`) and change `.env`, then `up` again:
+
+- **`DB_SSLMODE=verify-ca`** — the chain is still verified, but the hostname is
+ not. Connection still succeeds (the CA is trusted).
+- **Untrusted CA** — point the trust anchor at an unrelated CA to see a refusal:
+ generate a second CA (`openssl req -x509 -newkey rsa:2048 -nodes -keyout other.key -out other.pem -subj "/CN=Other" -days 1`)
+ and bind-mount `other.pem` over `/etc/guardian/tls/ca.pem`. The server **fails
+ fast** at startup with a certificate-verification error — it does **not** fall
+ back to an unverified connection.
+- **Hostname mismatch** — re-run `generate-certs.sh` after editing it to use a
+ different SAN (e.g. `DNS:not-postgres`). Under `verify-full` the connection is
+ **refused**; switch to `DB_SSLMODE=verify-ca` and the same cert is **accepted**
+ (hostname isn't checked at that level).
+
+## Run against a local build
+
+Until the feature is in a published image, build the server image from the repo
+and point the guide at it. From the repo root:
+
+```bash
+docker build --target server-runner -t guardian:dbtls-local .
+```
+
+Then in `.env` (the `postgres` feature is compiled in by default):
+
+```bash
+GUARDIAN_IMAGE=guardian:dbtls-local
+GUARDIAN_PULL_POLICY=missing
+```
+
+`docker compose up` now runs your local image. (`GUARDIAN_PULL_POLICY=missing`
+stops Compose from trying to pull the local-only tag.)
+
+## Cleanup
+
+```bash
+docker compose down -v # also removes the Postgres + cert volumes
+```
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| Postgres exits citing key permissions | `generate-certs.sh` not run, or `certs/` not readable by `db-cert-init`; re-run step 1 |
+| Server error naming `sslrootcert` | `certs/ca.pem` missing or not mounted; re-run step 1 |
+| Server refuses with a certificate-verification error | trust anchor doesn't match the server cert, or (under `verify-full`) the cert SAN ≠ `postgres` |
+| Works under `verify-ca`, fails under `verify-full` | hostname/SAN mismatch — the server cert's SAN must be `postgres` |
+
+See [`TROUBLESHOOTING.md`](../operations/troubleshooting.md#server-fails-to-start) for the
+database-TLS failure-to-cause mapping.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/index.md b/versioned_docs/version-0.16/builder/miden-guardian/index.md
new file mode 100644
index 00000000..f99be753
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/index.md
@@ -0,0 +1,80 @@
+---
+title: Miden Guardian
+sidebar_position: 0
+---
+
+# Miden Guardian
+
+Miden Guardian is an offchain coordination service built by [OpenZeppelin](https://www.openzeppelin.com/) for Miden accounts. It helps clients back up private account state, synchronize that state across devices, and coordinate multi-signer workflows without giving the Guardian provider unilateral control over the account.
+
+:::warning
+Guardian is a work in progress. Use the docs and the [OpenZeppelin Guardian repository](https://github.com/OpenZeppelin/guardian) as the current operator and integration reference, and expect interfaces and operational defaults to evolve.
+:::
+
+## The problem
+
+Miden's execution model requires clients to manage their own private state - accounts, notes, storage - locally on-device. While this provides strong privacy and scalability, it introduces real challenges:
+
+- **Solo-account users** risk losing access if local state is not backed up. Losing any part of the account state means losing access to the account itself.
+- **Shared-account users** risk having stale state due to a faulty or malicious participant withholding updates.
+- **Multi-device users** need all devices to see the same account state, but there is no public ledger to read from.
+
+On a public chain, the ledger is a universally readable source of truth. Every device and every signer can independently observe the latest state. In Miden's private account model, the canonical state is defined by the onchain commitment, but the full private state is not publicly readable. The coordination surface moves offchain.
+
+## What Guardian provides
+
+Guardian addresses these challenges by acting as an offchain coordination layer:
+
+- **Backup and recovery** - Account state is stored on Guardian, recoverable even if a device is lost.
+- **Multi-device sync** - Multiple devices push and pull state through Guardian, staying in sync with the latest canonical state.
+- **Multi-party coordination** - Shared accounts use delta proposals to coordinate threshold signing across participants.
+- **Integrity verification** - State changes are linked by commitments, validated against the Miden network, and acknowledged with a cryptographic signature.
+
+Guardian is not a private execution environment, a sequencer, or a rollup. Clients still execute transactions locally and submit proofs to Miden. Guardian stores and coordinates the state data that authorized clients submit to it.
+
+## Architecture at a glance
+
+| Layer | Responsibility | What remains authoritative |
+|---|---|---|
+| **Miden network** | Stores commitments and verifies account updates. | The canonical account commitment. |
+| **Miden client** | Executes transactions, manages local state, proves updates, and signs Guardian requests. | The user's keys and local verification logic. |
+| **Guardian server** | Stores snapshots, stores deltas, authenticates readers/writers, acknowledges accepted deltas, and coordinates proposals. | Only the data it has accepted and signed. |
+| **Storage backend** | Persists metadata, state snapshots, deltas, and pending proposals. | The append-only commitment chain, as verified by clients and the network. |
+
+Guardian is non-custodial. The provider cannot move funds unilaterally - it stores state and coordinates changes, but users retain cryptographic control over their accounts at all times.
+
+## Trust model summary
+
+Guardian has an explicit trust boundary:
+
+- **Safety**: The provider cannot forge account state, silently rewrite the commitment chain, or move assets without the required account signatures.
+- **Liveness**: The provider can delay, censor, or refuse requests. Users should keep recovery keys and provider-rotation paths available.
+- **Privacy**: Guardian protects state from unauthorized API callers, but the server stores the account state and delta payloads it receives. Treat the operator as able to observe submitted payloads unless your deployment adds a separate encryption layer or you operate the service yourself.
+- **Freshness**: Clients should verify acknowledgments and compare local state against the latest onchain commitment when freshness matters.
+
+## Learn more
+
+
+
+ What Guardian is and is not, the custody model, and the trust boundary.
+
+
+ A Guardian running locally in under 60 seconds with one command.
+
+
+ The server modules, storage modes, and the dashboard subsystem.
+
+
+ Readiness, configuration, and the path to a production deployment.
+
+
+ End-to-end Compose walkthroughs: signers, dashboard, observability, TLS.
+
+
+ Multi-party threshold signature workflows powered by Guardian.
+
+
+
+## Repository
+
+- [Miden Guardian](https://github.com/OpenZeppelin/guardian) - Guardian server, client SDKs, multisig client libraries, and specification
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/miden-compatibility.md b/versioned_docs/version-0.16/builder/miden-guardian/miden-compatibility.md
new file mode 100644
index 00000000..a4bb1a08
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/miden-compatibility.md
@@ -0,0 +1,178 @@
+---
+title: "Miden compatibility"
+---
+Which Miden protocol line each Guardian release targets, what changed between
+lines, and what each upgrade does to stored data.
+
+> Guardian's own version and Miden's are **not** aligned. Guardian 0.16.x runs on
+> Miden 0.15; Miden 0.16 arrives in Guardian 0.17.x. Read the matrix rather than
+> matching the numbers.
+
+This page is the single source of truth for those facts. Procedures live
+elsewhere and link here:
+
+| For | Read |
+|---|---|
+| Operator upgrade steps | [`PRODUCTION.md`](./operations/production.md) |
+| Diagnosing a version-mismatch symptom | [`TROUBLESHOOTING.md`](./operations/troubleshooting.md) |
+| SDK contract pinning and release policy | [`MULTISIG_SDK.md`](./reference/multisig-sdk.md#contract-version-pinning) |
+
+## Support matrix
+
+| Guardian | Miden protocol | `miden-protocol` / `miden-standards` | `miden-client` (Rust) | `@miden-sdk/miden-sdk` (npm) |
+|---|---|---|---|---|
+| 0.17.0 | 0.16 | `=0.16.1` | `=0.16.0` | `0.16.0` (exact) |
+| 0.16.x | 0.15 | `0.15.3` | `0.15.0` | `^0.15.8` |
+| 0.15.x | 0.15 | `0.15.x` | `0.15.0` | `^0.15.0` |
+| 0.14.x | 0.14 | n/a | `0.14.x` | `^0.14.0` |
+| 0.13.x | 0.13 | n/a | `0.13.0` | `^0.13.0` |
+| 0.12.x | 0.12 | n/a | `0.12.5` | `^0.12.5` |
+
+0.17.0 builds on the stable Miden 0.16 release. `@miden-sdk/miden-sdk` 0.16.0 embeds
+`miden-client` 0.16.0 and `miden-protocol` / `miden-standards` 0.16.1, which is why the
+Rust pins are 0.16.1 for the protocol crates and 0.16.0 for the client crates.
+
+Pins are exact on the 0.16 line, and the Rust and npm pins must move together: nothing
+at build time verifies that the npm SDK's embedded `miden-standards` matches the Rust
+pin, so the CI parity gates are what catch drift. See
+[`MULTISIG_SDK.md`](./reference/multisig-sdk.md#contract-version-pinning).
+
+**Upgrading from 0.16.x (Miden 0.15) to 0.17.0 (Miden 0.16)** is a protocol-line change:
+the guarded-multisig auth component now pays the transaction fee and transaction summaries
+bind the reference block, so nothing signed or stored on 0.15 verifies on 0.16. Stored Miden account data is
+reset by the embedded migration listed below, accounts must be recreated, and the Rust
+SDK's local `miden-client` SQLite store must be recreated (the browser IndexedDB store
+migrates in place). `miden-client` 0.16.0 also raises the MSRV to 1.98.1.
+
+**0.17.0-rc.1 to rc.3 were pre-releases on the Miden 0.16 release candidates**, published
+to npm under the `rc` dist-tag. They are not supported. Every rc pinned a different
+`auth_tx` procedure root than 0.17.0 (0.16.1 factored the fee payment into
+`miden::standards::auth::multisig::pay_bounded_fee`), so an account created on an rc is
+rejected with `UnsupportedContractVersion`, and a proposal still pending from an rc cannot
+be reproduced: `TransactionRequest` serialization changed and the auth arg commitment moved.
+Execute or cancel every pending proposal on the rc version, have GUARDIAN drop any that
+cannot be executed, then recreate the account on 0.17.0. Recreating the account does not
+clear proposals served for the old one.
+
+A Guardian server or SDK built on one protocol line rejects a node from another.
+Run a node matching the **Miden protocol** column.
+
+## Data resets
+
+Guardian has twice been unable to migrate stored account data across a Miden
+line. Both resets are embedded migrations that run automatically at server
+startup, both are irreversible, and both scope the purge to Miden rows using
+`account_metadata.network_config->>'kind'` so EVM accounts survive.
+
+| Migration | Introduced in | Deletes | Preserves |
+|---|---|---|---|
+| `2026-08-24-000001_miden_016_irreversible_reset` | Guardian 0.17.x | Miden rows in `delta_proposals`, `deltas`, `states`, `account_metadata`; `account_auth_state` by cascade | EVM rows, `admin_actions`, `auth_sessions`, `auth_challenges`, `storage_encryption_marker`, `worker_leases`, keystore |
+| `2026-06-14-000001_v015_account_id_cutover` | Guardian 0.15.x | pre-0.15 (v0 account ID) Miden rows in the same four tables | EVM rows, `admin_actions` |
+
+Both are Postgres-only. Filesystem-backed deployments reset by starting from
+empty storage and metadata directories, preserving the keystore directory.
+
+A deployment upgrading across more than one line runs both migrations in the same
+startup; the newer reset subsumes the older one.
+
+## Guardian 0.17.x on Miden 0.16
+
+Nothing stored under Miden 0.15 survives, because the account's on-chain surface
+moved in several independent ways:
+
+- **Procedure roots changed**, so stored proposals no longer address the
+ procedures they were signed against, and root-keyed storage reads
+ (`procedure_thresholds`) miss.
+- **ECDSA-k256 public-key commitments changed** in `miden-crypto` 0.28 to hash
+ native affine-coordinate limbs (`qx || qy` as little-endian `u32` limbs)
+ instead of the compressed SEC1 bytes, so stored approver commitments no longer
+ match their keys. Compressed SEC1 *serialization* is unchanged, which is why
+ this fails as a commitment mismatch rather than a decode error.
+- **The signature advice ABI changed** in `miden-vm` 0.29 to
+ `QX[8] || QY[8] || SIG_R[8] || SIG_S[8]`, and the recovery byte is no longer
+ part of it, so stored signatures cannot be replayed into a transaction.
+- **Storage slot names moved** from `openzeppelin::*` to `miden::standards::*`,
+ so stored state cannot be read back by name.
+- **The transaction summary layout changed** and now binds a chain anchor, so
+ stored summaries cannot be recomputed or re-verified. Proposals carry a
+ serialized `ChainAnchor` (wire field `chain_anchor`) and verification and
+ execution pin to it.
+- **The custody account is now the upstream `miden-standards`
+ `AuthGuardedMultisig` component** rather than Guardian's local MASM, and
+ `guardianEnabled` is gone: the guardian is always present.
+- **Transaction fees became the auth component's responsibility**, and
+ `AuthGuardedMultisig` now pays them. Its auth procedure calls
+ `miden::standards::fee::pay_fee` *before* building the transaction summary, so
+ the fee note and the vault withdrawal funding it fall inside what the cosigners
+ sign rather than being appended afterwards.
+
+ That makes the auth arg carry double duty. `fee::load_conversion_info` reads it
+ as the commitment `hash(CONVERSION_INFO || SALT)` and looks the preimage up in
+ the advice map; the same word then serves as the transaction summary salt. A
+ bare salt still satisfies the salt role but not the fee role: the lookup
+ misses, conversion info comes back empty, and `pay_fee` aborts with
+ `ERR_FEE_CONVERSION_INFO_MISSING` — though only once the computed fee is
+ non-zero, so a zero-`verification_base_fee` chain never notices.
+
+ **Every typed `create*Proposal` path in both SDKs therefore commits native
+ conversion info**, at rate 1/1 under the chain's own fee faucet. This is the
+ invariant change: a proposal's auth arg is no longer its salt.
+
+ Cross-SDK reconstruction survives because the committed value is *derived*, not
+ chosen. The faucet is read from the block the proposal is anchored at — the
+ anchor travels with the proposal and is checked against the summary's block
+ commitment before use — and the rate is fixed at 1/1. Both SDKs declare the
+ stored proposal salt on their transaction request builders:
+ `fee_conversion_salt(salt)` in Rust and `withFeeConversionSalt(salt)` in
+ TypeScript. The pinned Miden clients then derive and commit the same native
+ conversion info from the reference header used for execution.
+
+ Two consequences worth knowing:
+
+ - The pinned Miden clients classify `AuthGuardedMultisig` as
+ `CallerChosenSalt`. A request declares a salt, and the client commits the
+ chain-native conversion info under that salt. Components that do not read
+ fee conversion info still fail with
+ `TransactionRequestError::FeeConversionInfoUnsupported`.
+ - `pay_fee` spends the faucet and rate the committed conversion info names, so
+ what a guarded account must hold follows from what it commits. The built-in
+ typed proposal paths always commit the chain-native asset at rate 1/1, so on
+ a fee-charging chain an account driving them needs that native asset in its
+ vault or `pay_fee` aborts before the summary exists — and guardian-assisted
+ recovery cannot route around it, since it takes the same path. A custom
+ request that commits a different fee asset must instead fund *that* asset:
+ holding only it is enough to execute through fee payment, provided the
+ request needs no other assets. Whether the resulting transaction is then
+ *included* is a separate question — the batch builder decides what fee
+ asset and rate it accepts.
+
+ The exported builders always declare the fee conversion salt. A caller
+ assembling a raw custom request can omit it, but the resulting request works
+ only on a zero-fee chain. Typed proposal reconstruction always declares the
+ stored `salt_hex`.
+
+Data effect: full reset, see above. Operator steps:
+[`PRODUCTION.md`](./operations/production.md#upgrading-to-miden-016).
+
+## Guardian 0.15.x and 0.16.x on Miden 0.15
+
+Miden 0.15 invalidated account ID version 0: encoded version `0` is rejected, and
+every serialized `AccountDelta` or `TransactionSummary` embedding a v0 ID fails to
+deserialize. A v0 ID is a proof-of-work-derived commitment with no v1 equivalent,
+so there is no in-place migration. Addresses also moved to bech32m.
+
+Guardian 0.16.x stayed on Miden 0.15 and required no reset; the changes in that
+release were Guardian-side only.
+
+Data effect: the 0.15 cutover above, on the first 0.15 deploy.
+
+## Adding a line
+
+When Guardian adopts a new Miden line:
+
+1. Add a matrix row with the exact pins.
+2. Add a per-line section stating what broke and what it does to stored data.
+3. If data cannot be migrated, add the migration to the reset table and write the
+ operator steps in [`PRODUCTION.md`](./operations/production.md).
+4. Leave the procedural and symptom docs pointing here rather than restating the
+ version facts, so there is one place to update.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/operations/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/operations/_category_.json
new file mode 100644
index 00000000..4e65b67b
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/operations/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Operations",
+ "position": 4
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/operations/dashboard.md b/versioned_docs/version-0.16/builder/miden-guardian/operations/dashboard.md
new file mode 100644
index 00000000..af42ed26
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/operations/dashboard.md
@@ -0,0 +1,335 @@
+---
+title: "Operator Dashboard"
+sidebar_position: 2
+---
+The operator dashboard is Guardian's read-and-administer surface for
+humans. It lives in the same Guardian server process as the gRPC API but
+uses a **separate auth domain** and a **separate set of HTTP routes** under
+`/dashboard/*`. This doc explains the trust model, how operators enroll,
+how the local smoke example wires it up, and how the permission vocabulary
+is structured.
+
+Companion docs:
+- [Service architecture — Dashboard subsystem](../architecture/services.md#dashboard-subsystem)
+- [Secrets runbook — Operator public keys](../runbooks/secrets.md#operator-public-keys)
+
+## What it is
+
+A small HTTP API + browser UI that lets a known set of operators:
+- list and inspect accounts known to this Guardian
+- read per-account and global delta / proposal feeds
+- read account snapshots and metadata
+- (gated by permission) pause/unpause accounts
+
+It is **not** part of the per-account gRPC contract — clients of Guardian
+don't talk to it. Only operators do.
+
+## Trust model
+
+Two auth domains coexist in the same server process:
+
+```mermaid
+flowchart LR
+ subgraph PerAccount["Per-account API (gRPC + HTTP)"]
+ A1["Account-owned Falcon/ECDSA key signs each request"]
+ A2["Credentials verified via metadata/auth"]
+ end
+
+ subgraph Operator["Operator dashboard (/dashboard/*)"]
+ B1["Operator Falcon key signs a challenge"]
+ B2["Verified against the allowlist"]
+ B3["Session cookie issued, used for follow-up calls"]
+ end
+
+ PerAccount -. shares ACK signer for responses .-> Operator
+```
+
+Code references:
+- Allowlist loader:
+ [`crates/server/src/dashboard/allowlist.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/allowlist.rs)
+- Challenge/session issuance:
+ [`crates/server/src/dashboard/authz.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/authz.rs)
+- Middleware that gates `/dashboard/*`:
+ [`crates/server/src/dashboard/middleware.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/middleware.rs)
+- Permission vocabulary:
+ [`crates/server/src/dashboard/permissions.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/permissions.rs)
+
+The dashboard never sees an account's key material and an account's
+client never sees an operator's. They are separate by construction.
+
+## Session flow
+
+```mermaid
+sequenceDiagram
+ participant O as Operator (browser)
+ participant S as Guardian server
+ O->>S: GET /dashboard/auth/challenge (public key commitment)
+ S->>S: Check pubkey is in allowlist
+ S-->>O: Challenge nonce (TTL 5m)
+ O->>O: Sign challenge with Falcon SK
+ O->>S: POST /dashboard/auth/session (signed challenge)
+ S->>S: Verify signature, mint session
+ S-->>O: Set-Cookie: guardian_operator_session (TTL 8h)
+ O->>S: GET /dashboard/accounts (cookie attached)
+ S->>S: middleware: require_dashboard_session
+ S-->>O: paginated accounts
+```
+
+Defaults
+([`crates/server/src/dashboard/config.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/config.rs)):
+- Challenge TTL: 5 minutes
+- Session TTL: 8 hours
+- Max outstanding challenges per operator: 8
+- Cookie name: `guardian_operator_session`
+- Per-commitment auth budget: 6 burst / 30 per minute, partitioned by
+ `GUARDIAN_MAX_REPLICAS` in multi-replica deployments
+
+Session records use the configured coordination backend. Postgres-backed
+deployments share sessions across replicas, so ALB stickiness is not required
+and task replacement does not invalidate an unexpired session. Filesystem-backed
+development uses the in-memory coordination backend and remains single-process.
+
+For multi-replica deployments where you want cursors to validate across
+replicas, set `GUARDIAN_DASHBOARD_CURSOR_SECRET` to a 32-byte hex value
+shared by every task ([`config.rs:38`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/config.rs#L38)).
+
+## Pagination
+
+All list endpoints
+([`services/dashboard_pagination.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/services/dashboard_pagination.rs))
+share the same query-parameter shape:
+
+- `limit` — integer in `[1, 500]`. Default `50` when omitted or empty.
+ Out-of-range or non-integer values return HTTP 400 `invalid_limit`.
+- `cursor` — opaque, HMAC-signed token returned by the previous page.
+ Signed with the cursor secret (see above); tampered, expired, or
+ wrong-kind cursors return HTTP 400 `invalid_cursor`. Omit to start
+ from the first page.
+
+When `GUARDIAN_DASHBOARD_CURSOR_SECRET` is unset, each task generates a
+random secret at startup. Cursors then become invalid the moment a
+client is routed to a different task — set the env var on any
+multi-replica deployment.
+
+The global-delta feed
+([`GET /dashboard/deltas`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/api/dashboard_feeds.rs))
+also accepts a `status` filter
+([`services/dashboard_global_deltas.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/services/dashboard_global_deltas.rs)):
+
+- Allowed values: `candidate`, `canonical`, `retained`, `discarded`
+ (comma-separated to combine, e.g. `?status=candidate,canonical`).
+- Omitted or empty → all statuses.
+- Duplicates within the filter are silently coalesced.
+- Any other token returns HTTP 400 `invalid_status_filter`.
+
+Present `retained` rows as **"Unresolved / account unlocked"**, never as
+failed: the guardian stopped actively verifying and released the account
+slot, but the on-chain outcome is still uncertain and background
+reconciliation may promote the row to `canonical` until its retention
+TTL expires. The triage fields:
+
+- `status_reason` (feed + detail): why the row left the active candidate
+ path — `retry_exhausted` / `diverged` on `retained` rows (a `diverged`
+ row that later reconciles is direct evidence the divergence verdict
+ was spurious), `client_abandoned` on `discarded` rows.
+- `retained_expires_at` (detail): when the recovery net gives up for
+ good. Retained age is `now − status_timestamp`.
+- `base_matches_stored_state` (detail): whether the row still chains
+ from the stored account state; `false` means it is structurally
+ obsolete and can only age out.
+- The latest reconciliation activity is in the worker logs as stable
+ `event=reconcile_*` records (see TROUBLESHOOTING.md).
+
+`GET /dashboard/info` exposes the reconciliation settings
+(`retained_ttl_seconds`, `reconcile_interval_seconds`,
+`reconcile_page_size`) so operators can tell why retained rows are or
+are not being reconsidered — note that individual accounts back off as
+their recoverable rows age, so a retained row being probed less often
+than the configured interval is expected.
+
+## Permission vocabulary
+
+Permissions are server-defined; unknown strings are rejected at allowlist
+load time so a typo surfaces explicitly
+([`permissions.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/permissions.rs)).
+
+| Permission | Grants |
+|---|---|
+| `dashboard:read` | Read access to all `/dashboard/*` read endpoints. |
+| `accounts:pause` | Pause/unpause accounts. |
+| `policies:write` | Reserved for future policy writes — no endpoint currently requires it. |
+
+Wire strings are **case-sensitive** and **must not contain whitespace** —
+the parser rejects both.
+
+> **Current scope:** `dashboard:read` gates all read endpoints.
+> `accounts:pause` gates the pause/unpause endpoints (see [Account
+> pausing](#account-pausing) below). `policies:write` is reserved
+> vocabulary — accepted by the allowlist parser but no endpoint requires
+> it yet.
+
+## Account pausing
+
+Operators holding `accounts:pause` can halt and resume an account's
+state-changing operations. While paused, the server rejects calls on
+the state-transition, proposal, and EVM mutation paths
+(`PushDelta`, `PushDeltaProposal`, `SignDeltaProposal`, and the
+matching EVM proposal/session operations) with `409 GUARDIAN_ACCOUNT_PAUSED`
+and gRPC `FailedPrecondition`
+([`error.rs:97-101`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/error.rs#L97)). Read endpoints
+and `ConfigureAccount` remain available so an account can be
+reconfigured while paused.
+
+| Route | Permission | Body |
+|---|---|---|
+| `POST /dashboard/accounts/{id}/pause` | `accounts:pause` | `{ "reason": "" }` — required and validated. |
+| `POST /dashboard/accounts/{id}/unpause` | `accounts:pause` | `{ "reason": "" }` — optional. |
+
+Both endpoints are **idempotent**: pausing an already-paused account or
+unpausing a not-paused account succeeds without state change. Each
+transition is recorded in the audit log with the operator's commitment,
+the timestamp, and the supplied reason
+([`services/pause_account.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/services/pause_account.rs),
+[`services/unpause_account.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/services/unpause_account.rs)).
+
+Pause state lives in the account metadata (`paused_at`,
+`paused_reason`) — survives task restarts and follows the account across
+multi-stack deploys that share metadata storage.
+
+When a paused account is touched by a write path (`PushDelta`,
+`SignDeltaProposal`, `PushDeltaProposal`, …), the server returns
+`GUARDIAN_ACCOUNT_PAUSED` with the original `paused_reason` in the
+response body. See
+[`TROUBLESHOOTING.md`](./troubleshooting.md#error-code-reference).
+
+## Allowlist payload
+
+The operator allowlist is a Secrets Manager entry whose payload is one of:
+
+**Legacy array form** — every key implicitly gets `dashboard:read`:
+```json
+["0x", "0x"]
+```
+
+**Object array form** (recommended) — explicit permission sets:
+```json
+[
+ {
+ "public_key": "0x",
+ "permissions": ["dashboard:read", "accounts:pause"]
+ },
+ {
+ "public_key": "0x",
+ "permissions": ["dashboard:read"]
+ }
+]
+```
+
+Mixed arrays of bare strings and objects are accepted; duplicate
+`public_key` entries across the file are rejected.
+
+> **Terraform-managed allowlists are limited to the legacy array form.**
+> The `guardian_operator_public_keys` variable is typed `list(string)`
+> and Terraform writes `jsonencode(...)` of the list verbatim
+> ([`infra/operator_secrets.tf:12`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/operator_secrets.tf#L12)),
+> so every entry implicitly gets `dashboard:read` only. To grant
+> `accounts:pause` you must use the object form via a file
+> (`GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE`) or an externally managed
+> secret referenced by `guardian_operator_public_keys_secret_arn`.
+
+The server resolves the allowlist *source* from one of these env vars
+at startup ([`allowlist.rs:70`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/allowlist.rs#L70));
+the contents are re-read per authenticated request, so adding or
+removing operators does not require a task restart:
+
+| Env var | Source |
+|---|---|
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ID` | Secrets Manager secret name or ARN (set by Terraform on the ECS task). |
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE` | Local JSON file path — local development only. |
+
+## Enrolling an operator
+
+End-to-end procedure for adding operator Alice to a deployed Guardian.
+
+1. **Alice generates a Falcon keypair** on a trusted device (the same
+ keypair format the multisig SDK and the smoke example use; the
+ `examples/operator-smoke-web` README has a UI for this).
+2. Alice gives the public key (hex `0x…`) to the deploying operator.
+3. **Deployer updates the allowlist**:
+ - Terraform-managed: append the bare key to
+ `guardian_operator_public_keys` and redeploy (see [Secrets
+ runbook](../runbooks/secrets.md#adding-or-removing-an-operator)).
+ This path grants `dashboard:read` only.
+ - Externally-managed: `aws secretsmanager update-secret` with the new
+ payload — no ECS restart required.
+4. **Alice logs in** — challenge → sign → session. The change takes
+ effect on her next request; the server refreshes the allowlist on
+ every challenge issuance and every authenticated `/dashboard/*` call
+ ([`dashboard/state.rs:103-108`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/state.rs#L103),
+ [`dashboard/state.rs:284-324`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/state.rs#L284)).
+
+### Removing or revoking an operator
+
+Same shape, no restart:
+1. Update the secret payload to drop or change Alice's entry.
+2. Effect is immediate — the next challenge or authenticated request
+ from any task reloads the allowlist and rejects the removed key.
+ Currently-active sessions for the revoked operator are rejected at
+ their next authenticated call (the per-request reload catches them).
+
+## Local development
+
+To run the real operator UI ([`0xMiden/guardian-dashboard`](https://github.com/0xMiden/guardian-dashboard))
+against a local server with Docker Compose, follow
+[`guides/miden-dashboard`](../guides/miden-dashboard.md).
+
+For a lightweight check of the API itself, use
+[`examples/operator-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/operator-smoke-web) — it
+runs a browser harness that exercises challenge issuance, signed-session
+issuance, and the account listing endpoints against either a local server
+or a remote Guardian.
+
+Run a local Guardian with a file-based allowlist:
+```bash
+cat > /tmp/operators.json <<'EOF'
+[{ "public_key": "0x",
+ "permissions": ["dashboard:read", "accounts:pause"] }]
+EOF
+
+GUARDIAN_NETWORK_TYPE=MidenLocal \
+GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE=/tmp/operators.json \
+GUARDIAN_STORAGE_PATH=.guardian/storage \
+GUARDIAN_METADATA_PATH=.guardian/metadata \
+ cargo run --bin server
+```
+
+Then in another shell, follow the
+[`examples/operator-smoke-web`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/operator-smoke-web) README to
+point the harness at `http://localhost:3000`. The
+`smoke-test-operator-dashboard` skill drives this end-to-end.
+
+## Storage-mode caveats
+
+The dashboard surfaces a few aggregates (account counts, global feeds)
+that are cheap on Postgres but expensive on the filesystem backend. The
+server has a defensive cap: above
+`DEFAULT_FILESYSTEM_AGGREGATE_THRESHOLD` (1,000 accounts by default,
+[`config.rs:16`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/config.rs#L16)),
+cross-account aggregates on filesystem deployments may return a degraded
+marker rather than a count. This is intentional — filesystem mode is a
+dev convenience, not a production backend. See
+[Storage modes](../architecture/services.md#storage-modes).
+
+## Operations checklist
+
+When standing up the dashboard for a new stack:
+
+- [ ] Decide Terraform-managed or externally-managed allowlist.
+- [ ] Add at least one operator with `dashboard:read` before shipping —
+ otherwise the dashboard is unreachable.
+- [ ] Set `GUARDIAN_NETWORK_TYPE` for the stack; the dashboard
+ environment reported by `GET /dashboard/info` is derived from it.
+- [ ] If running ≥2 ECS tasks, pin
+ `GUARDIAN_DASHBOARD_CURSOR_SECRET` to a shared 32-byte hex value.
+- [ ] Verify a fresh challenge → session round trip from the smoke
+ example before considering the deploy live.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/operations/production.md b/versioned_docs/version-0.16/builder/miden-guardian/operations/production.md
new file mode 100644
index 00000000..5ea20e72
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/operations/production.md
@@ -0,0 +1,305 @@
+---
+title: "Production Guide"
+sidebar_position: 1
+---
+This is the production entry point for Guardian operators. It summarizes the
+supported production shape and points to the detailed deploy, architecture,
+configuration, and runbook docs.
+
+## Supported shape
+
+The reference production deployment is AWS ECS/Fargate running the Guardian
+server with the Postgres backend, RDS for durable state, and AWS Secrets
+Manager for deployment secrets.
+
+Production deployments should use:
+
+- `DEPLOY_STAGE=prod` for the Terraform stage profile.
+- `GUARDIAN_SERVER_FEATURES=postgres` for Miden-only deployments.
+- `GUARDIAN_SERVER_FEATURES=postgres,evm` when EVM proposal support is
+ required.
+- Amazon RDS for state, deltas, proposals, account metadata, and audit rows.
+- AWS Secrets Manager for ACK signing keys and deploy-time secrets.
+- Explicit `GUARDIAN_CORS_ALLOWED_ORIGINS` for browser clients.
+
+### ECDSA ACK signer: Secrets Manager or KMS
+
+The Falcon and ECDSA ACK keys default to AWS Secrets Manager, which is the
+path existing deployments use and remains fully supported. For the ECDSA signer
+specifically, new production deployments should prefer **AWS KMS**: the private
+key is generated in and never leaves KMS, so it is never resident in the
+Guardian process. Set `guardian_ack_ecdsa_kms_key_arn` and the server uses the
+KMS backend instead of the Secrets Manager secret (Falcon is unaffected).
+
+This is opt-in, not the default, because the KMS key is a distinct keypair:
+switching an existing deployment changes Guardian's ECDSA identity and requires
+the `SwitchGuardian` migration for existing accounts. Create the key and read
+the trade-offs in [`runbooks/secrets.md`](../runbooks/secrets.md#hosted-ecdsa-backend-aws-kms).
+
+Filesystem mode is a local development backend only. It has no durable admin
+audit table, no schema migrations, and cannot safely back multiple ECS tasks.
+
+### Running behind your own ingress (non-AWS)
+
+The rate limiter keys clients by IP, and that identity comes from the
+ingress in front of the server. The reference ALB deployment handles
+this automatically; if you run Guardian behind your own proxy or load
+balancer instead, these must hold:
+
+- Your ingress must **append or overwrite** `X-Forwarded-For` on **both**
+ listeners: the HTTP port and the gRPC port. Guardian keys on the
+ rightmost `X-Forwarded-For` entry, the one your ingress appended. Note
+ that proxies often need separate configuration for gRPC (for example
+ nginx `grpc_pass` does not add forwarding headers unless
+ `grpc_set_header` is configured explicitly).
+- If your ingress identifies callers with `X-Real-IP` instead, it must
+ **strip** any client-supplied `X-Forwarded-For`. `X-Forwarded-For` is
+ read first, so a caller that sends one wins over the `X-Real-IP` your
+ proxy set and picks its own rate-limit identity.
+- Only the ingress may reach the server ports (3000/50051). Forwarding
+ headers are trusted whenever present, so a client that can connect
+ directly can choose its own rate-limit identity.
+- If the ingress does not forward the client address at all (an
+ unconfigured proxy, Kubernetes `externalTrafficPolicy: Cluster` SNAT,
+ an L4 balancer without client-IP preservation), every client collapses
+ into one shared budget keyed on the proxy's address: one noisy client
+ then throttles everyone, and the sustained limit caps the whole
+ deployment's throughput.
+
+To check which case you are in, restart with
+`RUST_LOG=info,server::middleware::rate_limit=debug` (the per-rejection
+lines are `debug`, since refusals are expected traffic), trip the limiter
+and read the `Request rate limited` lines: `client_ip` must show real
+client addresses: not your proxy's address, not `unknown`, and not a
+value you forged in a test request. Two probes settle it: exhaust the
+budget from one machine and confirm a second machine on a different
+address still succeeds, then retry from the exhausted machine with a
+forged `X-Forwarded-For` prefix and confirm it stays throttled.
+
+## Production checklist
+
+Before treating a deployment as production-ready:
+
+- Set `DEPLOY_STAGE=prod`.
+- Build with `postgres`, plus `evm` if the EVM API must be served.
+- Bootstrap ACK secrets once with
+ `DEPLOY_STAGE=prod ./scripts/aws-deploy.sh bootstrap-ack-keys`.
+- For the ECDSA signer, decide between Secrets Manager (default) and KMS
+ (preferred for new deployments); if using KMS, create the key and set
+ `guardian_ack_ecdsa_kms_key_arn` per
+ [`runbooks/secrets.md`](../runbooks/secrets.md#hosted-ecdsa-backend-aws-kms).
+- Confirm `DATABASE_URL` is supplied through the Terraform-managed RDS secret.
+- Optionally enable storage encryption at rest: run
+ `./scripts/aws-deploy.sh bootstrap-storage-encryption-key`, then deploy with
+ `GUARDIAN_STORAGE_ENCRYPTION_SECRET_NAME` set, against an empty store (the Miden
+ 0.16 reset is the natural window). See "Storage encryption" below.
+- Review the RDS durability settings for the stack. The prod stage defaults
+ to 7-day backup retention (point-in-time recovery), deletion protection
+ on, and a final snapshot on destroy; Multi-AZ is opt-in via
+ `rds_multi_az`. See "Durability and recovery" below.
+- Set `GUARDIAN_CORS_ALLOWED_ORIGINS` to the exact browser origins that need
+ access.
+- If the operator dashboard is enabled, configure the operator allowlist
+ secret and use object entries when permissions beyond `dashboard:read` are
+ needed.
+- Before the first managed prod deploy, run
+ `./scripts/aws-deploy.sh bootstrap-dashboard-cursor-secret`; Terraform then injects the same
+ `GUARDIAN_DASHBOARD_CURSOR_SECRET` into every ECS task.
+- Validate `/`, `/pubkey`, and the relevant SDK or dashboard smoke path after
+ deploy.
+- Size `GUARDIAN_RATE_PER_MIN` for the combined HTTP **and** gRPC volume:
+ the sustained limit is keyed per IP alone, so gRPC traffic (the Rust
+ SDK's and benchmark harness's default transport) draws on the same
+ allowance as HTTP. Budgets sized for HTTP-only traffic under-provision
+ after the transport-bypass fix. `GUARDIAN_RATE_BURST_PER_SEC` is keyed
+ per IP and endpoint, so it applies to each HTTP path and gRPC method
+ separately and can be sized per-endpoint rather than in aggregate.
+- On the first deploy that ships gRPC rate limiting, verify keying on the
+ deployed gRPC path: against staging with a deliberately low
+ `guardian_rate_burst_per_sec`, exhaust the budget from one machine,
+ confirm a second machine on a different address is unaffected, then
+ confirm a forged `X-Forwarded-For` prefix from the exhausted machine
+ stays throttled. Owned by whoever runs the deploy.
+- On the AWS reference deployment, metrics are on by default: the endpoint
+ binds loopback inside the ECS task and an ADOT sidecar exports selected
+ metrics to CloudWatch dashboards and alarms — no external exposure, no
+ bearer token needed. See
+ [`SERVER_AWS_DEPLOY.md`](./server-aws-deploy.md#metrics-dashboard-and-alarms).
+- If you scrape Prometheus yourself in a **self-managed deployment**, set
+ `GUARDIAN_METRICS_ENABLED=true`, bind an explicitly routable
+ `GUARDIAN_METRICS_ADDR` only if the scraper lives outside the host or task,
+ keep the port reachable only from the scraper's network, and set
+ `GUARDIAN_METRICS_BEARER_TOKEN`. Note the AWS reference Terraform hard-binds
+ the listener to `127.0.0.1` and exposes no bind-address, security-group, or
+ token knobs — `cloudwatch_metrics_enabled = false` there leaves a
+ loopback-only endpoint that only an in-task collector (added by customizing
+ the module) can reach, not an external scraper. See the
+ [Observability guide](../guides/observability.md) for scraping and a
+ Grafana dashboard stack, and
+ [`CONFIGURATION.md`](../reference/configuration.md#runtime--metrics-prometheus) for
+ the env vars.
+
+## Durability and recovery
+
+On the reference stack, all Guardian state — account state, deltas,
+proposals, account metadata, and audit rows — lives in RDS Postgres. The
+Guardian server itself is stateless, so crash resistance reduces to the
+database's guarantees:
+
+- **Write durability.** Postgres WAL: a write acknowledged by the database
+ survives a crash of the instance. Guardian acknowledges a delta to the
+ client only after the database write succeeds.
+- **Point-in-time recovery.** RDS automated backups (daily snapshot plus
+ continuous WAL archiving) allow restore to any point within
+ `rds_backup_retention_days` (default 7). WAL is shipped roughly every
+ five minutes, which bounds worst-case data loss for a total instance
+ loss to about that window.
+- **Operator-error protection.** In the prod stage, deletion protection is
+ on and destroying the stack takes a final snapshot
+ (`-postgres-final`) by default.
+- **Standby failover.** Multi-AZ is **off by default** in every stage. Set
+ `rds_multi_az = true` if the deployment needs automatic failover to a
+ standby replica; this is an availability trade-off (roughly double the
+ instance cost), not a backup mechanism.
+
+What is deliberately **not** provided: cross-region replicas, automated
+disaster-recovery drills, or backup-failure alarms (see
+[`architecture/infra.md`](../architecture/infra.md#things-that-are-deliberately-not-here)).
+
+Two Guardian-specific caveats when restoring from a backup:
+
+- Restoring to an earlier point rewinds stored account state. Any delta
+ that canonicalized on-chain after the restore point cannot be
+ regenerated by the guardian — guarded accounts are private on Miden, so
+ only client devices hold the full state. Affected accounts fail
+ state-commitment verification until a device holding the newer state
+ re-syncs it (see the failure table in
+ [`CONCEPTS.md`](../getting-started/concepts.md#failure-and-recovery)).
+- If storage encryption is enabled, database backups contain ciphertext.
+ The Secrets Manager encryption key is part of the recovery set: losing
+ it makes every restored payload unrecoverable. Keep an out-of-band copy.
+
+The verification and restore procedure is in
+[`runbooks/backup-restore.md`](../runbooks/backup-restore.md).
+
+## Storage encryption
+
+Guardian can encrypt the sensitive stored payloads (account state, delta and
+proposal payloads) at rest, above whatever disk-level encryption the database
+already provides. It is opt-in: set a key source and the server encrypts; set
+none and behavior is unchanged.
+
+- Confidentiality boundary: only the payload JSON is encrypted — the account
+ `state_json`, delta `delta_payload`, and proposal `delta_payload`. Routing and
+ index columns (`account_id`, `nonce`, `status`, proposal `commitment`) stay
+ **plaintext** by design: they are needed for lookups and some are bound as AEAD
+ additional authenticated data (authenticated, not hidden). "Encrypted at rest"
+ here means payload confidentiality, not metadata confidentiality — anyone with
+ database read access still sees which accounts exist, their nonce/commitment
+ lineage, and proposal status. Use disk/database-level encryption if the index
+ metadata itself is sensitive in your threat model.
+
+- Production key source: AWS Secrets Manager, holding
+ `{ "active": "k1", "keys": { "k1": "" } }`. On the standard
+ stack, `./scripts/aws-deploy.sh bootstrap-storage-encryption-key` creates it and
+ a deploy with `GUARDIAN_STORAGE_ENCRYPTION_SECRET_NAME` set wires
+ `GUARDIAN_STORAGE_ENCRYPTION_KEY_SECRET_ID` plus the task-role
+ `secretsmanager:GetSecretValue` grant (same pattern as the ACK keys). `AWS_REGION`
+ is reused.
+- Enable against an **empty** store. The server writes a one-time marker on the
+ first encrypted write and then refuses to mix plaintext and ciphertext, so it
+ fails fast if a key is configured against a store that already holds plaintext
+ records. For Miden-only deployments the Miden 0.16 reset (which purges Miden
+ account data) is the natural enablement window; deployments that also retain EVM
+ account rows — which the reset preserves — must clear or migrate those
+ plaintext records before enabling encryption, or the first encrypted write will
+ fail fast against the mixed store.
+- Startup is fail-fast: a missing/malformed/wrong-length key, or more than one
+ key source, prevents startup rather than degrading to plaintext.
+- Key rotation: add a new entry to `keys` and move `active`; keep the old key so
+ existing records still decrypt. Bulk re-encryption tooling is not yet provided.
+
+Full configuration and a dev walkthrough are in
+[`CONFIGURATION.md`](../reference/configuration.md#storage-encryption-at-rest).
+
+## Upgrading to Miden 0.16
+
+> **One-time, irreversible: the first 0.16 deploy wipes all pre-0.16 Miden
+> account data. EVM accounts are unaffected.** Stored 0.15 Miden states, deltas,
+> proposals, and metadata can no longer be deserialized or recomputed, and cannot
+> be migrated. For what changed on the Miden side and why none of it survives, see
+> [`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md#guardian-017x-on-miden-016).
+> Guardian 0.17.x is the release that adopts Miden 0.16; Guardian 0.16.x runs on
+> Miden 0.15.
+
+What happens on the first 0.16 startup (Postgres backend):
+
+- The embedded reset migration
+ `2026-08-24-000001_miden_016_irreversible_reset` runs automatically via
+ `run_pending_migrations` and deletes Miden-network rows from
+ `delta_proposals`, `deltas`, `states`, and `account_metadata`. Rows whose
+ `account_metadata.network_config->>'kind'` is `evm` are preserved across all
+ four tables. Every other row is purged, including Miden rows and any row
+ orphaned from its metadata.
+- `account_auth_state` is cleared by cascade from `account_metadata`, so
+ per-signer replay floors for deleted accounts go with them.
+- The migration takes a brief `ACCESS EXCLUSIVE` lock on `account_metadata` so a
+ replica still running the old binary cannot write Miden rows mid-reset. It
+ fails fast (5s `lock_timeout`) rather than stalling startup; if that happens,
+ confirm the old server is stopped and restart.
+- **Preserved:** `admin_actions` (append-only audit), `auth_sessions`,
+ `auth_challenges`, `storage_encryption_marker`, `worker_leases`, and the
+ Guardian ACK/operator keystore. Guardian identity and operational audit data
+ survive the reset.
+- The migration is irreversible: its `down.sql` is a no-op. There is no
+ partial-salvage path and no legacy-account filtering: every incompatible
+ surface is removed at once rather than left for per-instance repair.
+- A deployment upgrading from before 0.15 also runs the older
+ `2026-06-14-000001_v015_account_id_cutover` in the same startup. Its effect is
+ subsumed by this reset (both purge Miden rows and preserve EVM rows), so the
+ steps below are the only ones that apply.
+
+Operator actions, in order:
+
+1. **Back up the database.** This is the only way to retain pre-0.16 records,
+ and the reset runs automatically on startup. Do this even if you believe the
+ data is disposable: for private accounts, Guardian may hold state the client
+ no longer has, and step 4 clears the client copy too.
+2. **Stop the old server** before deploying. The lock above is a backstop, not a
+ substitute.
+3. **Deploy against a matching Miden 0.16 node.** A 0.16 server against a 0.15
+ node fails on protocol mismatch, not on anything this reset controls.
+4. **Clear client-side state**: SDK SQLite stores, browser IndexedDB, and local
+ metadata under `~/.guardian`. Stale local metadata outlives the server reset
+ and will not deserialize.
+5. **Recreate and re-register accounts.** There is no in-place account
+ migration; users re-establish custody accounts and register them on the
+ Guardian. EVM accounts continue operating unchanged.
+6. **Discard all pending and offline proposals.** Exported proposal files and
+ unexecuted pending proposals from 0.15 are bound to the old summary layout
+ and procedure roots, so they can never execute.
+
+Filesystem-backed deployments have no migration step: start from empty storage
+and metadata directories, and preserve the keystore directory.
+
+## Where details live
+
+| Need | Read |
+|---|---|
+| Match a Guardian release to a Miden version | [`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md) |
+| Step-by-step setup for a specific run mode | [`guides/`](../guides/overview.md) |
+| Deploy or update the AWS stack | [`SERVER_AWS_DEPLOY.md`](./server-aws-deploy.md) |
+| Understand the AWS topology and Terraform ownership | [`architecture/infra.md`](../architecture/infra.md) |
+| Understand server storage modes and why prod uses Postgres | [`architecture/services.md`](../architecture/services.md#storage-modes) |
+| Check runtime and deploy-time env vars | [`CONFIGURATION.md`](../reference/configuration.md) |
+| Bootstrap, replace, or respond to ACK/operator/EVM secret issues | [`runbooks/secrets.md`](../runbooks/secrets.md) |
+| Verify database backups or restore from one | [`runbooks/backup-restore.md`](../runbooks/backup-restore.md) |
+| Migrate a deployed stack to verified database TLS | [`runbooks/enable-db-tls.md`](../runbooks/enable-db-tls.md) |
+| Configure dashboard operators and permissions | [`DASHBOARD.md`](./dashboard.md) |
+| Scrape Prometheus metrics and visualize them | [`guides/observability/`](../guides/observability.md) |
+| Diagnose deploy/runtime failures | [`TROUBLESHOOTING.md`](./troubleshooting.md) |
+
+## Non-goals
+
+This page does not replace the AWS deploy guide or the runbooks. Keep
+procedural steps in those docs so deployment behavior has one source of truth.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/operations/server-aws-deploy.md b/versioned_docs/version-0.16/builder/miden-guardian/operations/server-aws-deploy.md
new file mode 100644
index 00000000..8a95902a
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/operations/server-aws-deploy.md
@@ -0,0 +1,841 @@
+---
+title: "Deploying GUARDIAN Server to AWS ECS"
+sidebar_position: 3
+---
+This guide covers the current AWS deployment for Guardian. The AWS stack now uses Amazon RDS for PostgreSQL and no longer supports the legacy ECS-hosted Postgres runtime.
+
+The deployment surface supports two stage profiles:
+- `DEPLOY_STAGE=dev` keeps the current low-cost, fixed-capacity behavior
+- `DEPLOY_STAGE=prod` enables ECS autoscaling, RDS storage autoscaling, RDS Proxy, larger default RDS sizing, RDS deletion protection with a final snapshot on destroy, and benchmark-oriented runtime defaults
+
+## Published Docker images
+
+Prebuilt, versioned server images are published to the GitHub Container Registry
+(GHCR) at `ghcr.io/openzeppelin/guardian`, so you can pull a known-good image
+instead of building from source:
+
+```bash
+docker pull ghcr.io/openzeppelin/guardian: # e.g. v1.2.3, or latest
+```
+
+Images are multi-architecture (`linux/amd64` + `linux/arm64`) and fully
+runtime-configurable — every setting and secret is supplied at run time, never
+baked in (see [`docs/CONFIGURATION.md`](../reference/configuration.md)):
+
+```bash
+docker run --rm -p 3000:3000 -p 50051:50051 \
+ --env-file ./guardian.env \
+ ghcr.io/openzeppelin/guardian:
+```
+
+To run the published image with a Postgres backend locally, use the registry
+compose file (no local build):
+
+```bash
+cp .env.registry.example .env.registry # then set POSTGRES_PASSWORD in .env.registry
+GUARDIAN_VERSION= docker compose --env-file .env.registry -f docker-compose.registry.yml up
+```
+
+The stack is driven entirely by the gitignored `.env.registry` (see
+`.env.registry.example`): Compose reads `POSTGRES_PASSWORD` / `GUARDIAN_VERSION` from
+it for interpolation (via `--env-file`), and the server container loads it for
+runtime config. The shared repo `.env` (AWS/deploy config) is intentionally not
+used here, so this example never mutates it. The repo's default
+`docker-compose.yml` (and the `docker-compose.postgres.yml` override) instead
+build the server from source for contributors; `docker-compose.registry.yml` pulls
+the published image.
+
+Maintainers publish a version by running the **Docker Publish** GitHub Actions
+workflow one of two ways:
+
+- **On a GitHub Release.** Publishing a release auto-triggers the workflow: the
+ version and build ref come from the release tag, the build uses the `postgres`
+ feature, and an existing tag is never overwritten. The build first **waits for
+ required-reviewer approval** on the `release` environment before it pushes —
+ approve releases that should ship a server image, and decline ones that should
+ not (e.g. SDK-only releases that share the same `vX.Y.Z` tag line). A release
+ tag that does not match `vMAJOR.MINOR.PATCH[-prerelease]` fails the workflow.
+ Cutting the release as a **draft** (`gh release create … --draft`) does not
+ trigger the workflow — review it first, then publish the draft to start the build.
+- **Manual dispatch.** Pick the branch/tag/commit to build from, the version to
+ tag, the build features, and whether to overwrite an existing tag — for
+ off-release or one-off builds.
+
+In both cases a version containing `-` (e.g. `v1.2.3-rc.1`) is treated as a
+pre-release and does not move the `latest` tag.
+
+Published images are what the **AWS Deploy** workflow rolls out (next section).
+`scripts/aws-deploy.sh` still builds and pushes its own image for infrastructure
+changes and first-time stack setup.
+
+## Deploying a published image from GitHub Actions
+
+The **AWS Deploy** workflow (`.github/workflows/aws-deploy.yml`) deploys a
+published GHCR version to a Guardian stack without local AWS credentials or
+Terraform state. GitHub environments are named after the Miden network they
+serve and map onto AWS stacks through environment variables, so stack names and
+infrastructure profiles stay explicit settings rather than being inferred from
+the environment name:
+
+| Environment | Network | Stack (`STACK_NAME`) | Profile | Hostname |
+|---|---|---|---|---|
+| `devnet` | MidenDevnet | `guardian` | dev | `guardian-stg.openzeppelin.com` |
+| `testnet` | MidenTestnet | `guardian-prod` | prod | `guardian.openzeppelin.com` |
+
+Run it from the Actions tab with:
+
+- `environment`: `devnet` or `testnet`
+- `version`: a published tag such as `v1.2.3`. Only `devnet` accepts
+ pre-releases like `v1.2.3-rc.1`; every other environment is release-only.
+
+What a run does:
+
+1. Resolves the version tag to an immutable digest and verifies that digest
+ against the SLSA provenance attestation signed by the Docker Publish
+ workflow. For release-only environments the attestation must also show the
+ build ran from the `refs/tags/` release tag, so a manually
+ dispatched build of another branch cannot ship under a release version. This
+ happens before the environment's protection rules, so reviewers are only
+ asked to approve an already-verified digest.
+2. Authenticates to AWS with GitHub OIDC (bootstrap role, then role chaining to
+ the deploy role) and checks the stack's ECR repository and ECS service exist.
+3. Mirrors the verified digest into `-server` in ECR, tagged both
+ `` and `latest`. Moving `latest` keeps
+ `scripts/aws-deploy.sh plan` / `deploy --skip-build` resolving the deployed
+ image, so a later Terraform apply does not roll the service back.
+4. Registers a new revision of the task definition the service is currently
+ running, with only the image changed, and waits up to 20 minutes for the
+ rollout to stabilize.
+
+The run summary records the deployed image URI and the previous task-definition
+ARN. There is no automatic rollback: if a rollout fails, the summary prints the
+`aws ecs update-service` command to restore the previous revision, or re-run the
+workflow with the previously deployed version.
+
+The workflow only rolls out an image. When a release also changes the task
+definition (new environment variables, secrets, or IAM grants in `infra/`),
+apply that release's Terraform first with `scripts/aws-deploy.sh`, then deploy
+the image. Stacks that override the default `-server` / `-cluster`
+resource names in Terraform (or `ECR_REPO_NAME` in the script) are not
+supported by the workflow. The `guardian-evm` stack is also out of scope: GHCR
+release images are built with the `postgres` feature only.
+
+One-time setup per target (infra):
+
+- A GitHub environment named after the network (`devnet` / `testnet`) with
+ variables `AWS_REGION`, `ROLE_FOR_OIDC` (role trusted for GitHub OIDC),
+ `ROLE_TO_ASSUME` (deploy role reached via role chaining), and `STACK_NAME`
+ (`guardian` / `guardian-prod`). Add required reviewers on `testnet`.
+- Each environment must restrict **deployment branches** to `main`. Without
+ that, anyone able to dispatch the workflow could run an edited copy of it
+ from a feature branch and obtain the environment's AWS OIDC identity.
+- The OIDC role's trust policy must accept this repository's environment
+ subject claims (`repo:OpenZeppelin/guardian:environment:devnet` / `:testnet`).
+- The deploy role needs ECR push/pull on `-server`
+ (`ecr:GetAuthorizationToken`, `ecr:DescribeRepositories`,
+ `ecr:BatchCheckLayerAvailability`, `ecr:BatchGetImage`,
+ `ecr:InitiateLayerUpload`, `ecr:UploadLayerPart`, `ecr:CompleteLayerUpload`,
+ `ecr:PutImage`), `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`,
+ `ecs:RegisterTaskDefinition`, `ecs:UpdateService`, and `iam:PassRole` on the
+ stack's task and task-execution roles.
+- The ECR repository must already exist; `scripts/aws-deploy.sh build` creates
+ it on a new stack.
+
+## Prerequisites
+
+- [Terraform](https://developer.hashicorp.com/terraform/downloads) >= 1.0
+- AWS CLI configured with permissions for ECS, ECR, ELB, EC2, IAM, CloudWatch, RDS, and Secrets Manager
+- Docker installed locally
+- `jq` installed locally when deploying with `GUARDIAN_SERVER_FEATURES=postgres,evm`
+
+```bash
+aws sts get-caller-identity
+docker info
+terraform version
+```
+
+## Quick Start
+
+```bash
+aws sso login --profile
+
+set -a && source .env && set +a
+
+# Optional: build/deploy ARM64 instead of X86_64
+# export CPU_ARCHITECTURE=ARM64
+
+# Miden network the server runs against. The server requires this at startup;
+# the deploy script passes MidenTestnet unless you override it here.
+export GUARDIAN_NETWORK_TYPE=MidenTestnet
+
+# Optional: allow dashboard operators and let Terraform create the secret
+# export GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON='["0x","0x"]'
+
+# Optional: use an existing dashboard operator public keys secret instead
+# export GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN='arn:aws:secretsmanager:us-east-1:123456789012:secret:guardian/operators'
+
+# Optional: enable EVM support from config/evm/chains.json
+# export GUARDIAN_SERVER_FEATURES=postgres,evm
+# export GUARDIAN_EVM_CHAIN_CONFIG_FILE=config/evm/chains.json
+# export GUARDIAN_CORS_ALLOWED_ORIGINS=https://accounts.openzeppelin.com
+
+# Optional: choose the deployment profile
+export DEPLOY_STAGE=dev
+# export DEPLOY_STAGE=prod
+
+# Optional: override the stack base name or public hostname
+export STACK_NAME=guardian
+# export SUBDOMAIN=guardian-stg
+
+aws sts get-caller-identity
+./scripts/aws-deploy.sh deploy
+./scripts/aws-deploy.sh status
+```
+
+For a reviewable deployment, split image publishing, planning, and applying:
+
+```bash
+./scripts/aws-deploy.sh build
+./scripts/aws-deploy.sh plan
+./scripts/aws-deploy.sh deploy --skip-build
+./scripts/aws-deploy.sh status
+```
+
+This builds and pushes `${ECR_REPO_NAME}:latest`, plans Terraform against the immutable digest currently behind that tag, then applies using the existing ECR image without rebuilding. If you push a new image after `plan`, rerun `plan` before `deploy --skip-build`.
+
+## Terraform Variables
+
+If you need to override defaults, use `infra/terraform.tfvars`:
+
+```hcl
+aws_region = "us-east-1"
+
+# Optional: ECS/image architecture
+# cpu_architecture = "X86_64"
+# cpu_architecture = "ARM64"
+
+# Optional: derive resource names from a base stack name
+# stack_name = "guardian"
+
+# Only set this when bypassing scripts/aws-deploy.sh. The deploy script resolves
+# ECR latest to an immutable digest and passes server_image_uri via -var.
+# server_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/guardian-server@sha256:"
+
+# Optional: Postgres credentials (defaults derive from stack_name)
+# postgres_db = "guardian"
+# postgres_user = "guardian"
+# postgres_password = "guardian_dev_password"
+
+# Optional: managed database sizing overrides
+# Stage defaults:
+# - dev -> db.t3.micro, 20 GiB allocated, no storage autoscaling ceiling
+# - prod -> db.t3.medium, 50 GiB allocated, 200 GiB max allocated
+# rds_instance_class = "db.t3.medium"
+# rds_allocated_storage = 50
+# rds_max_allocated_storage = 200
+
+# Optional: Miden network for the server runtime
+# server_network_type = "MidenDevnet"
+
+# Optional: dashboard operator Falcon public keys managed by Terraform
+# guardian_operator_public_keys = [
+# "0x",
+# "0x",
+# ]
+
+# Optional: existing dashboard operator Falcon public keys secret
+# guardian_operator_public_keys_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:guardian/operators"
+
+# Optional: hosted ECDSA ACK signer backed by AWS KMS.
+# Setting this is all that is required: Terraform grants the ECS task role
+# kms:Sign + kms:GetPublicKey on the key and injects GUARDIAN_ACK_ECDSA_BACKEND
+# and GUARDIAN_ACK_ECDSA_KMS_KEY_ID into the server runtime. The key must be
+# ECC_SECG_P256K1 / SIGN_VERIFY. On this path the ECDSA Secrets Manager secret is
+# not needed; the Falcon ACK secret bootstrap is unchanged and still required in
+# prod.
+# guardian_ack_ecdsa_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/"
+
+# Optional: EVM runtime configuration
+# guardian_evm_allowed_chain_ids = "1,11155111"
+# guardian_evm_rpc_urls = "1=https://ethereum-rpc.publicnode.com,11155111=https://ethereum-sepolia-rpc.publicnode.com"
+# guardian_evm_entrypoint_address = "0x433709009b8330fda32311df1c2afa402ed8d009"
+# guardian_cors_allowed_origins = "https://accounts.openzeppelin.com"
+
+# Optional: stage/runtime capacity overrides
+# deployment_stage = "prod"
+# server_desired_count = 2
+# server_autoscaling_enabled = true
+# server_autoscaling_min_capacity = 2
+# server_autoscaling_max_capacity = 6
+# server_autoscaling_cpu_target = 65
+# server_autoscaling_memory_target = 75
+# rds_proxy_enabled = true
+# rds_proxy_subnet_ids = ["subnet-xxxxxxxx", "subnet-yyyyyyyy"]
+# In us-east-1, avoid subnets in us-east-1e/use1-az3 for RDS Proxy.
+# guardian_rate_limit_enabled = false
+# guardian_rate_burst_per_sec = 200
+# guardian_rate_per_min = 5000
+# guardian_db_pool_max_size = 32
+# guardian_metadata_db_pool_max_size = 32
+
+# Optional: application metrics (ADOT sidecar + CloudWatch dashboard/alarms).
+# Enabled by default; see "Metrics, Dashboard, And Alarms".
+# guardian_metrics_enabled = true # server Prometheus endpoint (loopback-only)
+# cloudwatch_metrics_enabled = true # ADOT sidecar + dashboard + alarms
+# metrics_namespace = "Guardian/Server"
+# alarm_actions = ["arn:aws:sns:us-east-1:123456789012:guardian-alerts"]
+# alarm_error_rate_threshold_percent = 5
+# alarm_latency_threshold_seconds = 1
+# alarm_cpu_threshold_percent = 85
+# alarm_memory_threshold_percent = 90
+
+# Optional: Route 53 hosted zone ID
+# route53_zone_id = "Z1234567890ABC"
+
+# Optional: Cloudflare DNS management
+# cloudflare_zone_id = "..."
+# cloudflare_api_token = "..."
+```
+
+## Database TLS verification
+
+By default the server `DATABASE_URL` uses `sslmode=require` — the connection is
+encrypted but the RDS certificate is **not verified**. To authenticate the
+database, provide a CA bundle via Secrets Manager and set the
+`rds_ca_bundle_secret_arn` Terraform variable.
+
+> **Migrating an already-deployed stack?** Follow the staged, fail-safe procedure
+> in [`runbooks/enable-db-tls.md`](../runbooks/enable-db-tls.md) (image-first,
+> staging-before-prod, RDS-Proxy caveat, rollback). The rest of this section is
+> the mechanism reference. The deployment then switches
+`DATABASE_URL` to `sslmode=verify-full&sslrootcert=`, and both the
+migration (libpq) and runtime (rustls) connections verify the certificate chain
+and hostname.
+
+**How delivery works (image stays CA-free).** The published image ships no CA
+bundle. When `rds_ca_bundle_secret_arn` is set, Terraform adds a small
+`rds-ca-initializer` **init container** to the task: it reads the secret, writes
+it to a shared in-task volume, sets permissions, and exits; Fargate won't start
+the Guardian container until it succeeds (`dependsOn: SUCCESS`). The Guardian
+container mounts the same volume read-only and reads the bundle as a plain file.
+The app never calls Secrets Manager and nothing is baked into the image.
+
+**Combined CA bundle (required for RDS).** Production routes `DATABASE_URL`
+through the **RDS Proxy** endpoint, which presents an AWS Certificate Manager
+certificate that chains to **Amazon Trust Services** roots (specifically **Amazon
+Root CA 1**) — *not* the Amazon RDS CA roots used by a direct instance. The secret
+MUST therefore contain **both** root sets so `verify-full` succeeds against either
+endpoint.
+
+> **Size limit — do NOT use the global RDS bundle.** Secrets Manager caps a secret
+> value at **64 KiB**. The `global-bundle.pem` (~165 KB) exceeds that and
+> `create-secret` will reject it. Use your **region-specific** RDS bundle (a few
+> KB) plus just **Amazon Root CA 1**, which keeps the combined bundle well under
+> the cap. The Rust loader already supports multiple roots in one PEM.
+
+Build it (mind the newline between files so the PEM blocks don't merge) and store
+it verbatim — plain PEM text, no encoding:
+
+```bash
+# Region-specific RDS bundle (replace us-east-1), ~4-5 KB
+curl -sS https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem -o rds.pem
+# All Amazon Trust Services roots (~6 KB total) — more rotation-tolerant than
+# pinning only the one the RDS Proxy chains to today; still well under 64 KiB.
+: > ats.pem
+for ca in AmazonRootCA1 AmazonRootCA2 AmazonRootCA3 AmazonRootCA4 SFSRootCAG2; do
+ curl -sS "https://www.amazontrust.com/repository/${ca}.pem" >> ats.pem; echo >> ats.pem
+done
+{ cat rds.pem; echo; cat ats.pem; } > rds-combined-ca.pem
+grep -c "BEGIN CERTIFICATE" rds-combined-ca.pem # sanity: total root count
+test "$(wc -c < rds-combined-ca.pem)" -lt 65536 || echo "WARNING: bundle exceeds the 64 KiB Secrets Manager limit"
+
+aws secretsmanager create-secret \
+ --name guardian-prod/server/rds-ca-bundle \
+ --secret-string file://rds-combined-ca.pem
+```
+
+> **Why Secrets Manager for a public cert?** CA roots aren't confidential, so this
+> is a *consistency* choice — it reuses the same secret-injection plumbing and IAM
+> pattern as `DATABASE_URL` and the ACK keys, with no new mechanism. The trade-offs
+> are the 64 KiB cap above and ~$0.40/secret/month. If a future bundle must exceed
+> 64 KiB, the next step is an S3 object (no size cap) or an EFS access point
+> fetched by the same init container — the Rust/app side would not change.
+
+Then set the ARN and apply:
+
+```hcl
+rds_ca_bundle_secret_arn = "arn:aws:secretsmanager:REGION:ACCOUNT:secret:guardian-prod/server/rds-ca-bundle-XXXXXX"
+```
+
+The execution role is granted `secretsmanager:GetSecretValue` on that ARN
+automatically. If the bundle is missing or malformed, the init container or the
+server preflight **fails closed** at startup rather than connecting insecurely.
+
+**Rotation.** Update the secret's value (it may hold old and new roots together
+for overlap), then force a new deployment so the init container re-reads the
+secret and rewrites the file. Because the task definition still points at the
+same secret ARN, changing only the secret value does **not** roll tasks on its
+own — force it explicitly:
+
+```bash
+aws ecs update-service --cluster --service --force-new-deployment
+```
+
+No image or code change is required; ensure the new roots are present before they
+become the only trusted ones.
+
+## Deploy
+
+### Script Commands
+
+| Command | Purpose |
+| --- | --- |
+| `./scripts/aws-deploy.sh build` | Build the Guardian server image and push it to ECR as `latest`. Does not run Terraform. |
+| `./scripts/aws-deploy.sh plan` | Run `terraform plan` using the immutable digest currently behind ECR `latest`. Does not build, push, or apply. |
+| `./scripts/aws-deploy.sh deploy` | Build and push the image, resolve ECR `latest` to an immutable digest, and run `terraform apply`. |
+| `./scripts/aws-deploy.sh deploy --skip-build` | Resolve the existing ECR `latest` image to an immutable digest and run `terraform apply` without rebuilding. |
+| `./scripts/aws-deploy.sh bootstrap-ack-keys` | Create the prod ACK key secrets in Secrets Manager. Refuses to overwrite existing secrets. With `TF_VAR_guardian_ack_ecdsa_kms_key_arn` set, creates only the Falcon secret (ECDSA is KMS-backed). |
+| `./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key` | Create the KMS ECDSA ACK signing key (`ECC_SECG_P256K1` / `SIGN_VERIFY`) and an `alias/${STACK_NAME}-ack-ecdsa` alias, then print the ARN to set. Refuses to overwrite an existing alias. |
+| `./scripts/aws-deploy.sh bootstrap-dashboard-cursor-secret` | Create the shared 32-byte dashboard cursor secret in Secrets Manager. Refuses to overwrite an existing secret. |
+| `./scripts/aws-deploy.sh status` | Print Terraform outputs for the active `STACK_NAME` and `DEPLOY_STAGE`. |
+| `./scripts/aws-deploy.sh logs` | Tail the deployed server's CloudWatch log group. |
+| `./scripts/aws-deploy.sh cleanup` | Run Terraform destroy for the active `STACK_NAME` and `DEPLOY_STAGE`. |
+
+`--skip-build` is meaningful for `deploy`; `plan` never builds or pushes an image. Use `build` before `plan` for a new stack or whenever ECR does not yet contain `${ECR_REPO_NAME}:latest`.
+
+### One-Step Deploy
+
+```bash
+./scripts/aws-deploy.sh deploy
+```
+
+The deploy script resolves the ECR `latest` tag to an immutable digest before calling Terraform, so image pushes always produce a real ECS task-definition revision instead of relying on tag reuse.
+It also keeps separate local Terraform state files per `STACK_NAME` and `DEPLOY_STAGE`, using `infra/terraform...tfstate` by default.
+
+AWS deployments must include the `postgres` server feature. The script defaults `GUARDIAN_SERVER_FEATURES` to `postgres`; set `GUARDIAN_SERVER_FEATURES=postgres,evm` only when deploying the optional EVM API surface.
+
+### Reviewable Build, Plan, Apply
+
+Use this flow when you want to inspect Terraform changes before applying them:
+
+```bash
+./scripts/aws-deploy.sh build
+./scripts/aws-deploy.sh plan
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+`build` creates the ECR repository if needed and pushes `${ECR_REPO_NAME}:latest`. Both `plan` and `deploy --skip-build` resolve that tag to an immutable digest before invoking Terraform. Do not rebuild or push a new `latest` between `plan` and `deploy --skip-build` unless you intend to apply a different image; rerun `plan` after any rebuild.
+
+For `DEPLOY_STAGE=prod`, bootstrap the ACK and dashboard cursor secrets once
+before the first deploy:
+
+```bash
+DEPLOY_STAGE=prod ./scripts/aws-deploy.sh bootstrap-ack-keys
+DEPLOY_STAGE=prod ./scripts/aws-deploy.sh bootstrap-dashboard-cursor-secret
+```
+
+The normal deploy path does not create or rotate these secrets. It expects the
+prod Secrets Manager entries to already exist. Terraform injects the cursor
+secret into every ECS task as `GUARDIAN_DASHBOARD_CURSOR_SECRET`.
+
+Secret names default to `${STACK_NAME}/server/ack-{falcon,ecdsa}-secret-key`, so distinct stacks (e.g. `guardian-prod`, `guardian-prod-eu`) automatically resolve to distinct secrets and multiple Guardian deployments can coexist in the same AWS account. Override per stack by setting `GUARDIAN_ACK_FALCON_SECRET_NAME` / `GUARDIAN_ACK_ECDSA_SECRET_NAME` before `bootstrap-ack-keys` and `deploy`; they flow into Terraform variables and the ECS task definition's `GUARDIAN_ACK_FALCON_SECRET_ID` / `GUARDIAN_ACK_ECDSA_SECRET_ID` env vars.
+
+The cursor secret defaults to
+`${STACK_NAME}/server/dashboard-cursor-secret`. To use an existing secret, set
+`GUARDIAN_DASHBOARD_CURSOR_SECRET_NAME` before both bootstrap and deploy.
+The deploy helper always passes this resolved name explicitly, so a stale
+`infra/terraform.tfvars` value cannot make validation and deployment select
+different secrets. For a customer-managed KMS key, its key policy must also
+allow the ECS task execution role to decrypt the secret.
+
+#### Prod with a KMS-backed ECDSA signer
+
+To keep the ECDSA private key in AWS KMS (never resident in the server process) while Falcon stays in Secrets Manager, create the key first and export its ARN before the rest of the flow. The script keys off `TF_VAR_guardian_ack_ecdsa_kms_key_arn` (the env var, not `terraform.tfvars`) to skip the ECDSA Secrets Manager secret, so it must be exported **before** `bootstrap-ack-keys` and `deploy`:
+
+```bash
+export DEPLOY_STAGE=prod STACK_NAME=
+
+./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key # creates the key, prints the ARN
+export TF_VAR_guardian_ack_ecdsa_kms_key_arn="arn:aws:kms:...:key/"
+./scripts/aws-deploy.sh bootstrap-ack-keys # Falcon only; skips ECDSA
+./scripts/aws-deploy.sh deploy
+```
+
+Terraform then grants the ECS task role `kms:Sign` + `kms:GetPublicKey` and injects `GUARDIAN_ACK_ECDSA_BACKEND=aws-kms` / `GUARDIAN_ACK_ECDSA_KMS_KEY_ID`. See [`runbooks/secrets.md`](../runbooks/secrets.md#hosted-ecdsa-backend-aws-kms) for key lifecycle, the immutable-spec caveat, and migrating an existing deployment (a new keypair, so a `SwitchGuardian` identity change).
+
+Dashboard operator public keys use a separate optional secret. The easiest
+deployment path is to pass the public keys to Terraform and let it create the
+stack-scoped secret:
+
+```bash
+export GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON='["0x","0x"]'
+```
+
+or in `terraform.tfvars`:
+
+```hcl
+guardian_operator_public_keys = [
+ "0x",
+ "0x"
+]
+```
+
+If you already manage the secret outside this stack, pass its ARN through
+`GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN` or
+`guardian_operator_public_keys_secret_arn`. An explicit secret ARN takes
+precedence over the Terraform-managed public key list.
+
+The ECS task role is granted read access only to the configured secret ARN. The
+server rereads that secret during operator auth checks, so adding or removing a
+key in the existing secret takes effect without an application restart. When
+Terraform manages the secret, update the public key list and rerun deploy.
+
+EVM deployments need the `evm` server feature plus server-owned chain config.
+By default, `scripts/aws-deploy.sh` derives allowed chain IDs, RPC URLs, and the
+shared EntryPoint address from `config/evm/chains.json`. It passes RPC URLs to
+Terraform as a stack-scoped Secrets Manager secret and the EntryPoint address as
+a normal ECS environment variable. To use an alternate JSON file, set
+`GUARDIAN_EVM_CHAIN_CONFIG_FILE`.
+
+You can still override the derived values by setting
+`GUARDIAN_EVM_ALLOWED_CHAIN_IDS`, `GUARDIAN_EVM_RPC_URLS`, or
+`GUARDIAN_EVM_ENTRYPOINT_ADDRESS` directly, or by passing existing secret ARNs
+through `GUARDIAN_EVM_ALLOWED_CHAIN_IDS_SECRET_ARN` and
+`GUARDIAN_EVM_RPC_URLS_SECRET_ARN`.
+
+When an EVM UI runs on a different origin, set
+`GUARDIAN_CORS_ALLOWED_ORIGINS` to a comma-separated list of exact origins.
+Wildcard origins are rejected. When this value is configured, the server enables
+credentialed CORS so browsers can include the host-only, `HttpOnly`
+`guardian_evm_session` cookie.
+
+If you still have an older local state file at `infra/terraform.tfstate`, move it manually before using the split-state workflow:
+
+```bash
+cp infra/terraform.tfstate infra/terraform.guardian.dev.tfstate
+cp infra/terraform.tfstate.backup infra/terraform.guardian.dev.tfstate.backup 2>/dev/null || true
+```
+
+Use `--skip-build` when the image already exists in ECR and you only need infra/runtime changes, or when you are applying immediately after a reviewed `plan`:
+
+```bash
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+For benchmark-oriented production deploys, prefer explicit overrides rather than changing the base prod profile in code. A typical starting point is:
+
+```bash
+set -a && source .env && set +a
+
+export DEPLOY_STAGE=prod
+export STACK_NAME=guardian-prod
+export TF_VAR_server_cpu=2048
+export TF_VAR_server_memory=4096
+export TF_VAR_server_desired_count=3
+export TF_VAR_server_autoscaling_min_capacity=3
+export TF_VAR_server_autoscaling_max_capacity=10
+export TF_VAR_rds_instance_class=db.r6g.large
+export TF_VAR_rds_allocated_storage=100
+export TF_VAR_rds_max_allocated_storage=400
+export TF_VAR_rds_proxy_subnet_ids='["subnet-25c1722b","subnet-4d0eca6c"]'
+export TF_VAR_guardian_db_pool_max_size=64
+export TF_VAR_guardian_metadata_db_pool_max_size=64
+export TF_VAR_guardian_rate_limit_enabled=false
+
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+## Validate
+
+```bash
+./scripts/aws-deploy.sh status
+curl https://guardian.openzeppelin.com/pubkey
+grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian.openzeppelin.com:443 guardian.Guardian/GetPubkey
+```
+
+## Metrics, Dashboard, And Alarms
+
+Application metrics ship to CloudWatch by default. Two switches control
+this: `guardian_metrics_enabled` turns on the server's Prometheus endpoint,
+and `cloudwatch_metrics_enabled` deploys the ADOT sidecar, EMF log group,
+IAM policy, dashboard, and alarms on top of it. The export pipeline
+cascades off with the endpoint, so `guardian_metrics_enabled = false` alone
+turns everything off. Disabling only `cloudwatch_metrics_enabled` keeps the
+endpoint without publishing CloudWatch custom metrics — but note the
+endpoint stays **loopback-only**, so that mode is useful only for an
+alternative in-task collector you add by customizing the module; the stack
+exposes no knobs for a routable bind address.
+
+- The server runs with `GUARDIAN_METRICS_ENABLED=true`, serving the Prometheus
+ exposition on `127.0.0.1:9464/metrics`. Fargate `awsvpc` containers share one
+ network namespace, so the endpoint is **loopback-only**: the sidecar reaches
+ it on `127.0.0.1` while nothing outside the task can — it is never exposed via
+ the ALB, target groups, or security groups. An externally scraped setup would
+ additionally require an explicit bind address, restricted security-group
+ ingress, and `GUARDIAN_METRICS_BEARER_TOKEN`; this module deliberately
+ configures none of that.
+- Latency and other duration metrics are **windowed**: the collector
+ delta-converts the Prometheus histograms (the awsemf exporter does not do
+ this for histograms on its own), so CloudWatch `Average` over a 5-minute
+ period reflects that period, not the process lifetime.
+- An **AWS Distro for OpenTelemetry (ADOT) Collector sidecar** in the server
+ task scrapes the endpoint every 60s and exports a curated selection of
+ metrics to CloudWatch as EMF log events (log group `/ecs//emf`).
+ CloudWatch materializes them as custom metrics under the
+ `metrics_namespace` namespace, which is **per stack**: `Guardian/Server` for
+ the default stack name, `Guardian-Prod/Server` for `guardian-prod`, and so
+ on, so stacks in one account never mix metrics.
+ The collector config is injected via `AOT_CONFIG_CONTENT`
+ (`infra/observability.tf`); no custom image or SSM parameter is involved.
+ Dimension sets come from Guardian's closed label sets (status, code, outcome,
+ kind, event, pool, transport); high-cardinality labels (route, method,
+ operation) are rolled up to keep the custom-metric count and cost bounded.
+ No scrape bearer token is configured on this path: network isolation (the
+ loopback-only listener inside the task) is the first defense layer described
+ in [the observability guide](../guides/observability.md#protecting-the-endpoint-production),
+ and no other network path to the endpoint exists.
+- The sidecar is **non-essential** (its exit does not stop the task) and its
+ memory is capped at 256 MiB, which bounds its worst-case share of the shared
+ task envelope — it is a bound, not isolation: both containers still draw
+ from the task's `server_memory` total, so size that with ~256 MiB of
+ headroom in mind. If the collector leaks it is OOM-killed at its cap, the
+ server keeps serving, and the `metrics-missing` alarm fires on the fleet
+ going dark. With more than one task, a single dead sidecar is not detected
+ by that alarm — the remaining tasks keep the metrics alive and the fleet's
+ Sums/Averages skew until the next deployment; per-task detection is a
+ possible follow-up.
+- Terraform creates a CloudWatch **dashboard** named `-server` (request
+ volume, error rate, latency, proposal/delta lifecycle, canonicalization
+ health, storage and DB-pool health, Miden RPC, ECS CPU/memory/tasks) and
+ these **alarms**:
+
+| Alarm | Fires when |
+|-------|------------|
+| `-http-5xx-rate` | HTTP 5xx responses (500/501/502/503/504) exceed `alarm_error_rate_threshold_percent` (default 5%) of requests for 15 min. ALB health checks count as successful requests and dilute the rate on low-traffic multi-task fleets — treat as a sustained-fault signal |
+| `-grpc-error-rate` | gRPC server-fault responses (`internal`, `unavailable`, `unknown`, `data_loss`, `deadline_exceeded`) exceed the same threshold for 15 min; the same health-check dilution applies |
+| `-http-latency` | Average HTTP latency exceeds `alarm_latency_threshold_seconds` (default 1s) for 15 min. Fleet average across all routes — continuous ALB health-check probes dilute it on low-traffic stacks, so treat it as a sustained-degradation signal |
+| `-canonicalization-failures` | Canonicalization passes (full, fast, or reconcile) report `error` or `partial` (some accounts failed) outcomes for 10 min |
+| `-metrics-missing` | Application metrics stop arriving — the constant `guardian_build_info` heartbeat disappears (metrics endpoint down, sidecar dead, or scrape failing) |
+| `-metrics-refresh-failures` | Slow-aggregate refresher attempts are failing; delta/proposal/account gauges are stale |
+| `-metrics-refresh-stale` | The refresh timestamp stopped advancing for ≥ 10 min (hung or dead refresher — catches what the failures counter cannot) |
+| `-ecs-cpu-high` / `-ecs-memory-high` | ECS service average CPU/memory exceeds `alarm_cpu_threshold_percent` (85%) / `alarm_memory_threshold_percent` (90%); must sit above the autoscaling targets (enforced at plan time) |
+
+To receive notifications, point the alarms at one or more SNS topics:
+
+```hcl
+alarm_actions = ["arn:aws:sns:us-east-1:123456789012:guardian-alerts"]
+```
+
+This stack does **not** provision the SNS topic or a chat integration. The
+supported notification path is: CloudWatch alarm → an existing **same-region
+SNS topic** (listed in `alarm_actions`) → [Amazon Q Developer in chat
+applications](https://docs.aws.amazon.com/chatbot/latest/adminguide/what-is.html)
+(formerly AWS Chatbot) subscribed to that topic → Slack channel. Create the
+topic and the chat subscription out of band, then pass the topic ARN here;
+alarms fire both `alarm_actions` and `ok_actions`, so the channel sees
+recovery too.
+
+Set `guardian_metrics_enabled = false` to turn everything off (no metrics env
+vars, no sidecar, no dashboard, no alarms — the CloudWatch flag cascades off
+with it), or only `cloudwatch_metrics_enabled = false` to keep the
+loopback-only endpoint without any CloudWatch export (see the caveat above
+about what that mode is useful for).
+
+### Verify metrics after a deploy
+
+```bash
+# Every name below is per stack; read them all from Terraform outputs.
+NS=$(terraform -chdir=infra output -raw metrics_namespace)
+DASH=$(terraform -chdir=infra output -raw metrics_dashboard_name)
+LOG_GROUP=$(terraform -chdir=infra output -raw server_log_group)
+ALARM=$(terraform -chdir=infra output -raw metrics_missing_alarm_name)
+
+# 1. Metrics arriving in the namespace (allow ~2 minutes after task start)
+aws cloudwatch list-metrics --namespace "$NS" --output table | head -40
+
+# 2. Dashboard exists and populates
+aws cloudwatch get-dashboard --dashboard-name "$DASH" --query DashboardName
+# then open CloudWatch > Dashboards > -server in the console
+
+# 3. Sidecar logs — scrape failures surface here as
+# "Failed to scrape Prometheus endpoint"
+aws logs tail "$LOG_GROUP" --log-stream-name-prefix adot --since 15m
+
+# 4. Exercise one alarm notification path end to end
+aws cloudwatch set-alarm-state --alarm-name "$ALARM" \
+ --state-value ALARM --state-reason "notification path test"
+# the next evaluation returns it to OK automatically
+```
+
+## Operations
+
+### Logs
+
+```bash
+./scripts/aws-deploy.sh logs
+```
+
+### Status
+
+```bash
+./scripts/aws-deploy.sh status
+```
+
+The script reads the state file for the active `STACK_NAME` and `DEPLOY_STAGE`. The current default path is:
+
+```text
+infra/terraform...tfstate
+```
+
+You can override that with `TF_STATE_PATH` if needed.
+
+### Destroy
+
+```bash
+./scripts/aws-deploy.sh cleanup
+```
+
+In the prod stage the RDS instance has deletion protection on and takes a
+final snapshot (`-postgres-final`) on destroy, so a prod cleanup
+fails until you set `TF_VAR_rds_deletion_protection=false` and re-apply.
+Restoring from the final snapshot is covered in
+[`runbooks/backup-restore.md`](../runbooks/backup-restore.md).
+
+ECR repositories are not managed by Terraform:
+
+```bash
+aws ecr delete-repository --repository-name guardian-server --force --region us-east-1
+```
+
+## Resources Created
+
+| Resource | Description |
+|----------|-------------|
+| ECS Cluster | Fargate cluster derived from `stack_name` |
+| ECS Service | Guardian server service |
+| Application Load Balancer | Internet-facing ALB derived from `stack_name` |
+| Target Groups | HTTP target group for port `3000` and gRPC target group for port `50051` |
+| RDS | Managed PostgreSQL instance and subnet group |
+| RDS Proxy | Managed PostgreSQL proxy in the production profile |
+| Secrets Manager | Secret containing `DATABASE_URL` for the server task |
+| Secrets Manager | Optional operator public keys secret for dashboard auth |
+| Secrets Manager | Optional EVM allowed chain IDs and RPC URLs secrets |
+| Secrets Manager | Secrets containing the Falcon and ECDSA ack private keys used to seed the server keystore in prod |
+| Security Groups | ALB, server, and database security groups |
+| CloudWatch Log Groups | Cluster execute-command logs, server logs, and the EMF metrics log group |
+| IAM Role | ECS task execution and runtime roles |
+| ADOT Sidecar | OpenTelemetry Collector container in the server task exporting Prometheus metrics to CloudWatch |
+| CloudWatch Dashboard | `-server` application and ECS overview |
+| CloudWatch Alarms | Error rate, latency, canonicalization, metrics pipeline, and ECS saturation alarms |
+
+## Outputs
+
+| Output | Description |
+|--------|-------------|
+| `alb_dns_name` | ALB DNS name |
+| `alb_url` | Full ALB URL |
+| `custom_domain_url` | Canonical service URL: https with a certificate, http when Terraform manages only the DNS record |
+| `grpc_endpoint` | Public gRPC endpoint when HTTPS is enabled |
+| `database_endpoint` | RDS endpoint used by the server |
+| `rds_proxy_endpoint` | RDS Proxy endpoint when enabled |
+| `rds_instance_class` | Effective RDS instance class |
+| `rds_allocated_storage` | Effective allocated RDS storage in GiB |
+| `database_url_secret_arn` | Secrets Manager ARN for the server `DATABASE_URL` |
+| `operator_public_keys_secret_arn` | Secrets Manager ARN used for dashboard operator public keys |
+| `operator_public_keys_secret_name` | Terraform-managed operator public keys secret name, when created |
+| `guardian_evm_allowed_chain_ids_secret_arn` | Secrets Manager ARN used for EVM allowed chain IDs |
+| `guardian_evm_rpc_urls_secret_arn` | Secrets Manager ARN used for EVM RPC URLs |
+| `guardian_evm_entrypoint_address` | Shared EVM EntryPoint address configured for the server |
+| `guardian_cors_allowed_origins` | Explicit CORS origins configured for the server |
+| `ack_falcon_secret_name` | Secrets Manager name for the Falcon ack key |
+| `ack_ecdsa_secret_name` | Secrets Manager name for the ECDSA ack key |
+| `dashboard_cursor_secret_name` | Secrets Manager name for the shared dashboard cursor key |
+| `ecs_cluster_arn` | ECS cluster ARN |
+| `server_service_arn` | Server ECS service ARN |
+| `metrics_namespace` | CloudWatch namespace receiving Guardian application metrics |
+| `metrics_dashboard_name` | CloudWatch dashboard name |
+| `metrics_emf_log_group` | Log group the ADOT sidecar writes EMF metric events into |
+| `metrics_missing_alarm_name` | Name of the metrics-pipeline heartbeat alarm for this stack |
+
+## Stage Profiles
+
+### Dev
+
+- single ECS task
+- no ECS autoscaling
+- direct ECS to RDS connection
+- no RDS Proxy
+- conservative Guardian runtime limits
+
+### Prod
+
+- higher ECS desired count
+- ECS service autoscaling
+- larger default RDS instance class and base storage
+- RDS storage autoscaling
+- RDS Proxy between ECS and RDS
+- higher Guardian runtime rate-limit and DB-pool defaults for benchmark traffic
+
+#### Horizontal scaling (multiple replicas)
+
+The prod profile runs 2–6 tasks behind the ALB. Because it sets `GUARDIAN_ENV=prod`
+and the Postgres backend, the server runs **shared coordination** (sessions,
+login challenges, and the canonicalization lease live in Postgres) — so any
+request lands on any replica and canonicalization runs on exactly one replica at
+a time. Terraform also sets `GUARDIAN_MAX_REPLICAS` from
+`effective_guardian_max_replicas` (the greater of desired count and autoscaling
+max, 6 by default) so global HTTP and dashboard commitment rate limits are
+partitioned across the steady-state fleet. A rolling deployment may allow up to
+`server_deployment_maximum_percent / 100` times the configured aggregate limit
+(2× by default). The default dashboard share is 5 requests per minute on a
+keep-alive-pinned replica. In prod, Terraform requires the pre-created dashboard
+cursor secret and injects the same value into every task, so dashboard
+pagination works across replicas. The server itself still warns and uses an
+ephemeral key when run without the variable outside this managed prod profile.
+Watch the per-replica `GUARDIAN_DB_POOL_MAX_SIZE` against Postgres
+`max_connections` (RDS Proxy absorbs most of this). Full operator guidance:
+[`runbooks/horizontal-scaling.md`](../runbooks/horizontal-scaling.md).
+
+## HTTPS And gRPC
+
+HTTPS is enabled when `acm_certificate_arn` is set. DNS can be managed through Cloudflare, Route 53, or both depending on which variables are provided.
+
+When HTTPS is enabled, the ALB routes standard HTTPS requests to the server HTTP port `3000` and gRPC requests for `/guardian.Guardian/*` to the server gRPC port `50051`. The public gRPC endpoint uses the same hostname on port `443`.
+
+On Apple Silicon hosts, `CPU_ARCHITECTURE=X86_64` builds are slower because Docker builds `linux/amd64` images under emulation. Switching to `ARM64` avoids that local emulation cost, but it also changes the ECS task runtime architecture.
+
+## Migrating An Existing ECS-Postgres Stack
+
+The current Terraform configuration is RDS-only. There is no supported dual-mode deployment that keeps the old ECS Postgres service alive after apply.
+
+Use this cutover flow for an existing stack:
+
+1. Capture the current stack state:
+ ```bash
+ ./scripts/aws-deploy.sh status
+ ```
+2. Create a logical PostgreSQL backup from the existing ECS-hosted database before applying the updated stack.
+3. Apply the updated RDS-backed Terraform stack:
+ ```bash
+ ./scripts/aws-deploy.sh deploy --skip-build
+ ```
+4. Restore the backup into the new RDS database.
+5. Validate the public service:
+ ```bash
+ ./scripts/aws-deploy.sh status
+ curl https:///pubkey
+ grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' :443 guardian.Guardian/GetPubkey
+ ```
+6. Confirm the old Postgres ECS service and Cloud Map database-discovery resources are gone from AWS before treating the cutover as complete.
+
+## Troubleshooting
+
+- If the server task fails during startup, check `./scripts/aws-deploy.sh logs` first and confirm the reported `database_endpoint` matches the expected RDS host.
+- If a prod deploy fails before Terraform starts, confirm the fixed prod ACK secrets exist by running `./scripts/aws-deploy.sh bootstrap-ack-keys` once and then retrying the deploy.
+- If RDS subnet-group creation fails, verify the selected subnets cover at least two subnets for the database deployment.
+- If gRPC works against the ALB directly but fails on the public hostname, check Cloudflare gRPC settings on the zone.
+- If the `metrics-missing` alarm fires or the dashboard is empty, tail the sidecar stream (`aws logs tail /ecs/ --log-stream-name-prefix adot`) — scrape failures appear as `Failed to scrape Prometheus endpoint`, and export failures reference `awsemf`.
+
+## Legacy Script
+
+The legacy deployment logic has been replaced by the Terraform-backed `scripts/aws-deploy.sh`.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/operations/troubleshooting.md b/versioned_docs/version-0.16/builder/miden-guardian/operations/troubleshooting.md
new file mode 100644
index 00000000..7e6a1640
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/operations/troubleshooting.md
@@ -0,0 +1,657 @@
+---
+title: "Troubleshooting"
+sidebar_position: 4
+---
+Common Guardian failures and how to resolve them. Organised by symptom
+first, then by error code.
+
+For concepts (lifecycle, trust model, recovery flows) see
+[`docs/CONCEPTS.md`](../getting-started/concepts.md).
+For local-dev setup see [`docs/LOCAL_DEV.md`](../getting-started/local-dev.md).
+
+## By symptom
+
+### Client and node disagree about the network version
+
+A Guardian server or SDK built on the Miden 0.16 line rejects a 0.15 node
+(and vice versa) at the RPC boundary: the Miden client sends the genesis
+commitment with every request, so a version mismatch surfaces as a gRPC
+rejection when connecting or syncing, not as silent corruption. Point the
+client at a node running the matching Miden line (devnet runs the 0.16
+node; for local work run a matching `miden-node`).
+
+### State created on Miden 0.15 fails to load after the 0.16 upgrade
+
+Deserialization errors such as `Unsupported version. Got '[0, 0, 3]'`, a
+local client store that errors on open, or a demo that panics on stale
+`~/.guardian` metadata all mean the same thing: state serialized under
+Miden 0.15 is not readable under 0.16, and the 0.16 devnet is a fresh
+chain, so 0.15-era accounts and notes no longer exist on-chain. There is
+no migration: delete local miden-client stores (`store.sqlite3`),
+`~/.guardian` metadata, and browser IndexedDB state, then recreate
+accounts. The Guardian server does not keep 0.15 account records either: the
+first 0.16 startup runs an irreversible reset that deletes Miden account
+metadata, states, deltas, and proposals (EVM rows are preserved). So after the
+upgrade any remaining failure of this kind is a *client-side* leftover, not
+server state. Operator steps are in
+[`PRODUCTION.md`](./production.md#upgrading-to-miden-016); what changed between
+Miden lines is in
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md).
+
+### Server fails to start
+
+Most startup failures are environment misconfiguration. Check in order:
+
+1. **`GUARDIAN_NETWORK_TYPE` unset, non-Unicode, or unrecognized.** The server exits
+ before binding any port with
+ `Failed to resolve network type: GUARDIAN_NETWORK_TYPE is not set; accepted values: ...`
+ (or `contains non-Unicode data` / `has unrecognized value "..."` for
+ malformed values and typos like `tesnet`). There is no fallback network.
+ Replace the variable with `MidenLocal`, `MidenTestnet`, or
+ `MidenDevnet` (short forms `local`/`testnet`/`devnet` work,
+ case-insensitive).
+2. **Miden node unreachable or Miden RPC settings invalid.** The initial
+ node connection retries transient failures for ≈35 seconds (5 attempts
+ with backoff) before failing startup with
+ `Failed to create network client: failed to connect to the Miden RPC endpoint` —
+ a brief node blip at boot no longer kills the server, but a genuinely
+ down or wrong endpoint still does, as do TLS/certificate
+ misconfigurations, which fail immediately without retrying. An invalid
+ `GUARDIAN_MIDEN_RPC_ENDPOINT` (not an origin-only `http(s)` URL in the
+ form `scheme://host[:port]`) also fails immediately. Embedded credentials,
+ paths, queries, and fragments are rejected because tonic does not send them
+ as authentication. Note that
+ `GUARDIAN_MIDEN_RPC_MAX_ATTEMPTS` never affects canonicalization: those
+ reads make one attempt per pass and transient failures are recovered by
+ the next scheduled pass. See [CONFIGURATION.md](../reference/configuration.md).
+3. **`DATABASE_URL` missing under `--features postgres`.** The builder
+ panics with `"DATABASE_URL environment variable is required"`. Either
+ set it or rebuild without the `postgres` feature.
+4. **Filesystem paths not writable.** Filesystem builds use
+ `GUARDIAN_STORAGE_PATH`, `GUARDIAN_METADATA_PATH`, and
+ `GUARDIAN_KEYSTORE_PATH` when set, defaulting to
+ `/var/guardian/storage`, `/var/guardian/metadata`, and
+ `/var/guardian/keystore` respectively. Startup fails if the process
+ cannot create or write to those paths — common on dev machines where
+ `/var/guardian` doesn't exist or isn't owned by the running user.
+ Either set the env vars to a writable location or `mkdir -p`
+ `/var/guardian/{storage,metadata,keystore}` with the right
+ permissions.
+5. **Postgres migrations fail.** The Postgres path runs migrations at
+ startup. If the DB user lacks `CREATE` permissions, startup fails.
+ Grant `CREATE` on the schema or run migrations as a privileged user.
+ For a migration that times out, crash-loops the task, or breaks
+ authentication mid-deploy, see [Postgres migrations block startup or fail
+ mid-deploy](#postgres-migrations-block-startup-or-fail-mid-deploy).
+6. **ACK secrets missing in prod.**
+ `scripts/aws-deploy.sh deploy` refuses to apply if either ACK secret
+ is missing. Run `DEPLOY_STAGE=prod ./scripts/aws-deploy.sh bootstrap-ack-keys`
+ first (see [Secrets runbook](../runbooks/secrets.md#bootstrap-first-prod-deploy)).
+7. **Operator allowlist source not set.** If you intend to use the
+ dashboard, set `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ID` (prod) or
+ `GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE` (local). Without either, the
+ dashboard is unreachable.
+8. **Database TLS misconfigured.** With a verifying `sslmode`, startup fails
+ closed before migrations run. Map the error:
+ - error naming `sslmode` (`allow`/`prefer` or an unknown value) → choose an
+ explicit mode: `disable`, `require`, `verify-ca`, or `verify-full`.
+ - error naming `sslrootcert` (missing/unreadable/empty file, or `system`) →
+ mount a readable PEM CA bundle and point `sslrootcert=` at it
+ (`sslrootcert=system` is unsupported).
+ - connection refused with a certificate-verification error → wrong CA for the
+ server, an expired certificate, or (under `verify-full`) a hostname that
+ doesn't match the certificate's SAN. For AWS RDS Proxy, ensure the bundle
+ includes the Amazon Trust Services roots, not only the RDS CA roots.
+ - works under `verify-ca` but fails under `verify-full` → hostname/SAN
+ mismatch with the endpoint. See [Database TLS](../reference/configuration.md#database-tls).
+9. **Replay-protection state file missing or inconsistent (filesystem builds).** Startup
+ fails with `Replay-protection state file ... is missing` when
+ `.metadata/auth_state.json` has been deleted from a store whose
+ `accounts.json` was already migrated off legacy timestamps. Starting
+ anyway would reset replay protection and re-accept previously seen
+ request timestamps, so the server refuses instead. Restore
+ `auth_state.json` from backup together with the rest of the metadata
+ directory, including `.metadata/auth_state_legacy_floor_v1` when it
+ exists. If a restore predates that marker, startup re-runs floor
+ repair and synthesizes an account-level floor from the highest stored
+ signer timestamp for every account, including ones created after the
+ per-signer migration. A newly added signer whose first request
+ timestamp is at or below that floor is rejected once; the next
+ strictly later timestamp succeeds. If no backup exists, recreating
+ `auth_state.json` with the literal content `{}` lets the server start.
+ That is an explicit operator decision to accept a replay window as
+ wide as the timestamp skew allowance. If startup instead reports
+ replay state with no matching account metadata, restore
+ `accounts.json` and `auth_state.json` from the same backup; Guardian
+ will not rewrite or discard the unmatched floors.
+
+### Postgres migrations block startup or fail mid-deploy
+
+Migrations run at server startup, before the connection pools are built.
+Each migration runs in a single transaction, so a failure never leaves a
+half-applied schema: the task exits, the orchestrator restarts it, and it
+retries from the same point.
+
+**Check which migrations are applied.** Compare the database against the
+migration directory (`crates/server/migrations/`, whose directory-name
+timestamps are the versions):
+
+```sql
+SELECT version FROM __diesel_schema_migrations ORDER BY version DESC LIMIT 5;
+```
+
+**`Timed out after 60s waiting for the migration advisory lock`.** Only one
+replica migrates at a time, serialised by a Postgres advisory lock. This
+message means another replica held it for the whole wait, or a session died
+mid-migration without releasing it. Find the holder:
+
+```sql
+SELECT a.pid, a.state, now() - a.xact_start AS age, left(a.query, 120) AS query
+ FROM pg_locks l
+ JOIN pg_stat_activity a USING (pid)
+ WHERE l.locktype = 'advisory';
+```
+
+The lock is released when its session ends, so a restart of the stuck replica
+clears it. Do not unlock it manually while another replica is still migrating.
+
+**`Failed to run migrations: ... canceling statement due to lock timeout`.**
+Migrations that rewrite a hot table take an explicit table lock with a 5s
+`lock_timeout`, so they fail fast rather than queueing every later writer
+behind them. A concurrent long-running transaction on the locked table is the
+usual cause:
+
+```sql
+SELECT pid, now() - xact_start AS age, state, left(query, 120) AS query
+ FROM pg_stat_activity
+ WHERE xact_start IS NOT NULL
+ AND now() - xact_start > interval '5 seconds'
+ ORDER BY age DESC;
+```
+
+A restart normally succeeds. Repeated failures mean a persistent long
+transaction, not a broken migration.
+
+**Authenticated requests fail on some replicas during a deploy.** A migration
+that changes a table the *previous* binary writes is not backward compatible,
+and the deployment keeps old tasks serving until the new one is healthy
+(`deployment_minimum_healthy_percent = 100`). Between the migration committing
+and the old tasks draining, requests routed to an old task fail. This is
+deliberate: failing closed beats writing state the new schema cannot represent.
+It is expected for `2026-07-31-000001_account_auth_state` and
+`2026-08-13-000001_auth_state_per_signer`. To eliminate the window rather than
+ride it out, stop the old tasks before the new one migrates, which trades the
+partial errors for brief full downtime.
+
+### Guardian public key changes unexpectedly
+
+**Treat this as a security event** until you can confirm intentional
+rotation:
+
+1. Stop trusting deltas signed under the new key.
+2. Check the deployment audit trail: who ran
+ `aws secretsmanager update-secret` on the ACK secret ARNs? CloudTrail
+ `GetSecretValue` events identify the principals.
+3. Check task restart timing: ACK keys are read once at startup, so a
+ pubkey change implies either a rotation event or a task that came up
+ in a different environment (e.g. `GUARDIAN_ENV` unset → ephemeral
+ filesystem keys).
+4. If unrotated, follow the
+ [compromise response runbook](../runbooks/secrets.md#compromise-response).
+
+Common benign causes:
+- Local dev with `GUARDIAN_KEYSTORE_PATH` pointing at a tmpfs that was
+ wiped between restarts.
+- Running with `GUARDIAN_ENV` unset and no `AWS_REGION` — the server
+ falls back to filesystem keystore and auto-generates a fresh key.
+
+### Signed requests are rejected at the auth layer
+
+This section covers the auth-middleware verdicts, `401` with
+`code: authentication_failed` or `code: authentication_replay`. If your
+request was *authenticated* but still rejected (e.g.
+`403 authorization_failed`, `403 signer_not_authorized`,
+`400 commitment_mismatch`, `409 conflict_pending_*`), jump straight to
+the [error code reference](#error-code-reference): the signature was
+fine, the service layer rejected the operation.
+
+The two 401 codes mean different things (issue #367 split them):
+
+- **`authentication_replay`** (`meta.retryable: true`). The request was
+ correctly signed, but its `x-timestamp` was not strictly greater than
+ the last timestamp Guardian accepted *from the same signer* on that
+ account. This is the replay-protection CAS, not a credential problem:
+ it happens when two in-flight requests from one client land out of
+ order, or when the same key is used from two processes at once. Both
+ SDK clients retry it automatically (bounded, fresh timestamp and
+ signature per attempt); a user-visible `authentication_replay` means
+ the condition persisted through the retry budget; look for a second
+ process (background poller, second tab, another host) signing with
+ the same key. Replay state is scoped per `(account, signer)`, so
+ other cosigners of a multisig never cause this for you.
+- **`authentication_failed`** (`meta.retryable: false`, terminal) for
+ three reasons:
+ 1. **Clock skew.** Timestamps must be within ±5 minutes of server time
+ ([`metadata/auth/credentials.rs:6`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth/credentials.rs#L6)).
+ Sync the client clock (NTP) or check for container time drift.
+ 2. **Invalid or unauthorized signature.** The signer's public-key
+ commitment is not in the account's authorized set, or the signature
+ does not verify.
+ 3. **Modified payload after signing.** The signature covers the
+ request body hash. Mutating the body (proxy reformatting, JSON
+ re-serialise) invalidates the signature. Sign-then-send; do not
+ transform between.
+
+Headers required on every authenticated request: `x-pubkey`,
+`x-signature`, `x-timestamp`. If any are missing the response is also
+`authentication_failed`. Never retry `authentication_failed`; it will
+not succeed until the underlying cause (clock, key, payload) changes.
+
+**Mixed server/client rollout:** SDK clients retry only
+`authentication_replay`; they never retry `authentication_failed`. A replay CAS
+reported under the older authentication code, or received by a client without
+replay-specific retry handling, can therefore surface as a terminal 401 until
+both sides use the same error contract.
+
+The same server upgrade changes HTTP `/configure` failures from a
+`ConfigureResponse` carrying `success: false` to the standard
+`{ code, message, meta }` error envelope. Update direct HTTP integrations that
+inspect the old body shape before rolling out the server; gRPC continues to use
+the shared protobuf response.
+
+### Pending proposals never resolve
+
+A proposal stays `pending` until enough cosigners sign and someone
+promotes it via `PushDelta`. If it sits too long:
+
+- **Threshold not met.** Count signatures: the proposal needs `n` of `m`
+ per the account configuration. Use `GetDeltaProposal` to see who has
+ signed.
+- **Pending limit reached.** `POST /delta/proposal` returns `409` with
+ `code: pending_proposals_limit` once an account has
+ `GUARDIAN_MAX_PENDING_PROPOSALS_PER_ACCOUNT` (default `20`) pending.
+ Resolve or cancel some.
+- **Canonicalization backlog.** Promoting a proposal to canonical depends
+ on the candidate's matching Miden update being observed. Check the
+ canonicalization worker logs and Miden RPC health.
+- **Storage backend write failures.** A failing metadata backend will
+ return `storage_error` on signing attempts. Check disk space (filesystem)
+ or DB connectivity (Postgres).
+
+### Candidates are being retained or discarded
+
+Delta moves `candidate` → `retained` (default) or `candidate` →
+`discarded` (retention disabled, or a client abandon). The cause is one
+of:
+
+1. The corresponding Miden proof was never submitted.
+2. The proof was submitted but the on-chain commitment differs from the
+ one Guardian acknowledged — usually because another device advanced
+ the account state in parallel.
+3. RPC endpoint targets the wrong network — Guardian polled the wrong
+ ledger and never saw the update.
+4. The canonicalization grace period (default 10 minutes) elapsed before
+ the proof landed.
+
+A `retained` delta is not final: the dedicated reconcile pass keeps
+probing the chain (backing off as the row ages) and promotes it
+automatically if the transaction ever shows up, for up to
+`retained_ttl_seconds` (default 24 h). The `status_reason` on the
+dashboard feed says which verdict parked it (`retry_exhausted` /
+`diverged`); a `diverged` row that later reconciles means the
+divergence verdict was spurious (e.g. a lagging RPC node).
+
+Recovery for the client: check the delta's status first — if it flipped
+to `canonical`, the transaction landed and there is nothing to redo.
+Otherwise `GET /delta/since` → replay canonical chain → rebuild the
+transaction → resubmit (this supersedes the retained row).
+
+Operator checks:
+- Canonicalization worker is running (look for `jobs::canonicalization`
+ log lines).
+- Miden RPC endpoint reachable (`rpc_unavailable` in logs indicates it
+ isn't).
+- No `network_error` storms.
+- `guardian_canonicalization_candidates_total{outcome=...}` breaks down
+ what the worker decided per candidate (`retained` is the default
+ give-up path; `diverged` and `discarded` are the delete paths when
+ retention is disabled; `stale_base` means a promotion was rolled back
+ because the stored state moved mid-pass and will retry next tick;
+ `reconciled` / `reconcile_deferred` / `reconcile_expired` are the
+ reconcile pass resolving retained rows).
+- `guardian_canonicalization_candidate_age_seconds` growing without
+ bound means candidates are not converging — check Miden RPC health
+ and the discard outcomes above.
+- `guardian_canonicalization_fast_runs_total{outcome=...}` and
+ `guardian_canonicalization_fast_run_duration_seconds` expose failures and
+ latency of the promotion-only pass without changing the full-pass gauges,
+ age histogram, or fetched-row counter.
+- `guardian_canonicalization_reconcile_runs_total{outcome=...}` and
+ `guardian_canonicalization_reconcile_run_duration_seconds` do the same
+ for the recoverable-delta reconcile pass.
+- `RUST_LOG=server::jobs::canonicalization=debug` emits one
+ `Fast-promotion pass completed` summary per fast tick, including empty passes,
+ with page, candidate, account-batch, deadline, and cursor-progress fields.
+- Retention and reconciliation emit stable `event` / `reason` fields for
+ log-based triage (with `account_id`, `nonce`, and `age_seconds` /
+ `retention_reason` / `expires_at` where applicable):
+ - `event=candidate_retained reason=retry_exhausted|diverged`
+ - `event=reconcile_deferred reason=chain_at_stored_base|chain_probe_unavailable|end_state_not_on_chain|base_no_longer_applies|recomputed_commitment_mismatch|no_matching_recoverable_delta`
+ - `event=reconcile_skipped reason=obsolete_base`
+ - `event=reconcile_promoted`
+ - `event=reconcile_expired`
+ - `event=reconcile_superseded`
+ The `chain_at_stored_base` / `chain_probe_unavailable` deferrals are
+ logged (debug/info) but deliberately not counted in
+ `guardian_canonicalization_candidates_total` — a healthy steady state
+ probes every due account and finds the chain unmoved, and counting
+ that would dwarf every other outcome.
+- `guardian_canonicalization_commitment_mismatches_total` counting up
+ means a client omitted `new_commitment` or claimed one that differs
+ from the recomputed value. The full pass can promote using the value it
+ independently verifies; the fast pass defers the candidate to that full
+ path. In either case, investigate the client.
+
+### `commitment_mismatch` on `PushDelta`
+
+The client tried to apply a delta on top of a state Guardian doesn't
+believe is current. Always recoverable:
+
+```bash
+GET /delta/since?account_id=...&nonce=
+```
+
+Replay the returned canonical deltas locally, then resubmit your new
+delta. This is the same pattern as a Git fast-forward.
+
+### Stale state served by Guardian
+
+Symptoms: client reads look "behind reality" relative to Miden. Causes:
+
+- Canonicalization worker stalled (RPC down, DB write failures).
+- Operator is intentionally censoring (run the
+ [provider rotation flow](../getting-started/concepts.md#provider-rotation)).
+- Backend lag — Postgres replication or filesystem fsync latency.
+
+Always compare against Miden before signing high-value transactions; see
+the [client verification checklist](../getting-started/concepts.md#client-verification-checklist).
+
+### Account is paused
+
+State-transition, proposal, and EVM mutation calls against the account
+(`PushDelta`, `PushDeltaProposal`, `SignDeltaProposal`, and the matching
+EVM proposal/session operations) return `409 GUARDIAN_ACCOUNT_PAUSED`
+(gRPC `FailedPrecondition`). Reads and `ConfigureAccount` still work.
+
+- **Confirm:** check `GET /dashboard/accounts/{id}` — paused accounts
+ report `paused_at` and `paused_reason`.
+- **Resume:** an operator holding `accounts:pause` calls
+ `POST /dashboard/accounts/{id}/unpause` with an optional `{"reason": "..."}`
+ body. The pause/unpause cycle is idempotent and audit-logged. See
+ [`DASHBOARD.md`](./dashboard.md#account-pausing).
+- **Don't bypass:** the pause is enforced server-side at the metadata
+ layer, not in the client. There is no env var or feature flag to
+ disable it.
+
+### Rate limits triggered
+
+Over HTTP: `429` with `code: rate_limit_exceeded` and a `Retry-After`
+header. Over gRPC: `RESOURCE_EXHAUSTED` with a `retry-after` metadata key
+(seconds) and the same `rate_limit_exceeded` envelope in the status
+details. The sustained limit is keyed per IP alone, so heavy gRPC
+traffic (the Rust SDK and benchmark harness default) can exhaust the
+sustained allowance for HTTP calls from the same client, and vice versa.
+The burst limit is keyed per IP and endpoint, and HTTP paths never
+collide with gRPC method names, so burst buckets are not shared.
+The rejection counter `guardian_rate_limit_rejections_total` carries a
+`transport` label to tell the two surfaces apart. Rate-limit rejections
+happen before any handler runs, so retrying after the hint is always safe.
+
+ALB gRPC health checks (`/guardian.Guardian/GetPubkey`) are metered like
+any other traffic, keyed per ALB-node address. Their volume is far below
+any sane budget, but a global limit below `GUARDIAN_MAX_REPLICAS`
+partitions each replica's budget to zero and would fail health checks and
+cycle tasks; the prod builder refuses to start in that configuration, dev
+builds only warn.
+
+If clients report throttling at request rates well below the configured
+budget, check the `Request rate limited` lines' `client_ip` field. They
+are logged at `debug` (rejections are expected traffic, and their volume
+tracks the flood being shed), so enable them with
+`RUST_LOG=info,server::middleware::rate_limit=debug`.
+The proxy's address (or `unknown`) on every line means your ingress is
+not forwarding the client address, so all clients share one budget:
+common with unconfigured reverse proxies (nginx `grpc_pass` needs
+explicit `grpc_set_header` for forwarding headers), Kubernetes
+`externalTrafficPolicy: Cluster`, or L4 balancers without client-IP
+preservation. See
+[PRODUCTION.md](./production.md#running-behind-your-own-ingress-non-aws).
+
+Server knobs (set on the task, not per-account):
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_RATE_LIMIT_ENABLED` | `true` | Set `false` only in test environments. |
+| `GUARDIAN_RATE_BURST_PER_SEC` | `10` (dev), `200` (prod) | Requests per one-second window. |
+| `GUARDIAN_RATE_PER_MIN` | `60` (dev), `5000` (prod) | Sustained rate. |
+| `GUARDIAN_MAX_REQUEST_BYTES` | `1048576` (1 MB) | Reject larger bodies. |
+
+If you legitimately need higher throughput, raise these via the deploy
+script or Terraform variables rather than disabling rate limiting.
+
+### Dashboard not reachable
+
+- **Allowlist empty.** Without at least one operator entry, every
+ challenge fails. Add an operator (see
+ [`docs/DASHBOARD.md`](./dashboard.md#enrolling-an-operator)).
+- **Stale browser session.** Operator sessions are per-task. After a
+ multi-task deploy, you may be routed to a task that did not issue your
+ cookie. Re-authenticate.
+- **`GUARDIAN_OPERATOR_PUBLIC_KEYS_*` env not set.** No source means no
+ allowlist means the dashboard refuses every login. Check task env.
+
+### Browser dashboard returns CORS errors
+
+By default
+([`middleware/cors.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/middleware/cors.rs)) the
+server is permissive: when `GUARDIAN_CORS_ALLOWED_ORIGINS` is unset or
+empty, every origin is allowed and credentials are **not** advertised
+(useful for local dev). Setting the variable switches to a strict
+credentialed allowlist:
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_CORS_ALLOWED_ORIGINS` | unset (allow-any, no credentials) | Comma-separated explicit origins (e.g. `https://accounts.openzeppelin.com,https://admin.openzeppelin.com`). Wildcard `*` is rejected because credentialed CORS requires explicit origins. |
+
+If the browser console shows a CORS preflight failure after deploy,
+either (a) the origin isn't in the allowlist, or (b) the value still
+contains `*` and the server failed startup — check task logs for the
+`{ALLOWED_ORIGINS_ENV} must use explicit origins` error.
+
+## Error code reference
+
+All Guardian error responses carry a stable `code` string. Wire strings
+come from
+[`crates/server/src/error.rs:206-247`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/error.rs#L206).
+
+### Authentication and authorization
+
+| Code | HTTP | First check |
+|---|---|---|
+| `authentication_failed` | 401 | Clock skew, invalid/unauthorized signature, modified payload, missing headers. Terminal; never retried. |
+| `authentication_replay` | 401 | Correctly signed but the timestamp lost the per-signer replay CAS. `retryable: true`; SDK clients retry it automatically with a fresh timestamp and signature. See [the auth-layer section](#signed-requests-are-rejected-at-the-auth-layer). |
+| `authorization_failed` | 403 | Account credentials don't authorize the operation. |
+| `signer_not_authorized` | 403 | Signer isn't on the proposal's allowed signer set. |
+| `GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION` | 403 | Operator dashboard call requires a permission the operator doesn't have. Response body carries `missing_permissions: string[]` (lex-sorted, deduplicated) and `retryable: false`. See [`DASHBOARD.md`](./dashboard.md#permission-vocabulary). |
+
+### Resource lookup
+
+| Code | HTTP | First check |
+|---|---|---|
+| `account_not_found` | 404 | Account ID typo or `/configure` never called. |
+| `state_not_found` | 404 | Account configured but no state pushed. |
+| `delta_not_found` | 404 | Wrong account/nonce; check `GetDeltaSince`. |
+| `proposal_not_found` | 404 | Proposal expired or already executed. |
+| `account_data_unavailable` | 503 | Backend transient failure; retry. |
+
+### Conflict and concurrency
+
+| Code | HTTP | First check |
+|---|---|---|
+| `account_already_exists` | 409 | `/configure` called twice for the same account. |
+| `conflict_pending_delta` | 409 | A non-canonical delta is in-flight; wait for it to finalise. |
+| `conflict_pending_proposal` | 409 | Pending proposals exist; resolve before pushing a direct delta. |
+| `pending_proposals_limit` | 409 | Account hit `GUARDIAN_MAX_PENDING_PROPOSALS_PER_ACCOUNT` (default 20). |
+| `proposal_already_signed` | 409 | This signer already signed this proposal. |
+| `GUARDIAN_ACCOUNT_PAUSED` | 409 (gRPC `FailedPrecondition`) | Account is paused by an operator. Response body includes the operator-supplied `paused_reason`. Unpause via `POST /dashboard/accounts/{id}/unpause` (requires `accounts:pause`). See [`DASHBOARD.md`](./dashboard.md#account-pausing). |
+| `GUARDIAN_ACCOUNT_RELEASED` | 409 (gRPC `FailedPrecondition`) | The account switched to a different guardian (a canonicalized `switch_guardian` delta moved the guardian key away from this server) and this server released it. Response body includes `released_at`. Reads keep working; mutations stay refused until the wallet re-onboards via `/configure`. |
+
+### Validation
+
+| Code | HTTP | First check |
+|---|---|---|
+| `invalid_input` | 400 | Generic validation failure; the message explains. |
+| `invalid_account_id` | 400 | Malformed account ID. |
+| `invalid_delta` | 400 | Delta payload failed schema or commitment validation. |
+| `invalid_commitment` | 400 | Commitment string isn't a valid hex hash. |
+| `commitment_mismatch` | 400 | `prev_commitment` doesn't match server's view; use `GetDeltaSince` to catch up. |
+| `invalid_proposal_signature` | 400 | Signature doesn't verify against the proposal payload. |
+| `invalid_network_config` | 400 | `Configure` payload's network config is malformed. |
+| `invalid_cursor` | 400 | Pagination cursor doesn't decode. |
+| `invalid_limit` | 400 | Pagination limit out of range. |
+| `invalid_status_filter` | 400 | Status filter string isn't in `{candidate, canonical, retained, discarded}`. |
+| `unsupported_for_network` | 400 | Endpoint not available for the account's network. |
+| `unsupported_evm_chain` | 400 | EVM chain ID not in the configured allowlist. |
+| `invalid_evm_proposal` | 400 | EVM proposal payload validation failed. |
+| `insufficient_signatures` | 400 | Threshold not met for a multi-sig execute. |
+
+### Network and infrastructure
+
+| Code | HTTP | First check |
+|---|---|---|
+| `rpc_unavailable` | 502 | Miden RPC endpoint unreachable. Check the configured endpoint and Miden node health. |
+| `rpc_validation_failed` | 502 | Miden RPC returned an error during validation. |
+| `network_error` | 502 | Miden network call failed mid-flight. |
+| `rate_limit_exceeded` | 429 | Backoff using the `Retry-After` header; tune `GUARDIAN_RATE_*` if legitimately needed. |
+| `data_unavailable` | 503 | Cross-account aggregate degraded (filesystem backend above `DEFAULT_FILESYSTEM_AGGREGATE_THRESHOLD`). Distinct from `account_data_unavailable`, which is account-scoped. |
+
+### Server-side
+
+| Code | HTTP | First check |
+|---|---|---|
+| `storage_error` | 500 | Persistence backend rejected the write. Check disk (filesystem) or DB (Postgres) health. |
+| `signing_error` | 500 | ACK signer failed. Check the keystore mount and Secrets Manager IAM. |
+| `configuration_error` | 500 | Server misconfiguration. Almost always means a startup-time env var was wrong. |
+
+## Logging and observability
+
+The server emits `tracing` logs — `text` by default, `json` when `GUARDIAN_LOG_FORMAT=json` (see [`CONFIGURATION.md`](../reference/configuration.md#logging)). `text` uses ANSI colors only when stdout is a TTY; `json` emits flattened JSON with span context for CloudWatch Logs Insights.
+
+Hot-path service handlers emit request events at `debug` — enable them with `RUST_LOG=server=debug` or `RUST_LOG=server::services=debug`. At the default `info` filter each request emits one span-close line instead, carrying the span's fields (account ID, nonce, commitment, signer/match counts) and `time.busy` / `time.idle`:
+
+```
+2026-08-19T11:53:31.172132Z INFO push_delta_proposal{account_id="0x1234…" nonce=7 commitment="0xabcd…" signer_count=2}: server::services::push_delta_proposal: close time.busy=4.1ms time.idle=112µs
+```
+
+That line is emitted whether the request succeeded or failed. This matters because the centralized error lines (`guardian error (HTTP 5xx)`, `guardian error (gRPC internal)`) are emitted from the `GuardianError` → response conversion, which runs after the service span has closed: they carry `code` and `detail` only, and the immediately preceding close line is what identifies the account. Low-volume domain milestones (`Account configured`, `Delta proposal created`, `Delta proposal signed`) keep their own `info` line.
+
+`resolve_account` is a nested helper rather than a request boundary, so its span is `debug` and it contributes no close line at `info`.
+
+The per-read `Commitment mismatch during state verification` (`network::miden`) is also `debug`; persistent divergence is surfaced by the canonicalization processor's streak-gated WARN (confirmed divergence), not the per-read log.
+
+Useful filters:
+
+```bash
+# Watch canonicalization worker decisions (including confirmed divergence WARN)
+RUST_LOG=server::jobs::canonicalization=debug
+
+# Watch per-request debug events
+RUST_LOG=server=debug
+RUST_LOG=server::services=debug
+
+# Watch auth verifier rejections
+RUST_LOG=server::middleware::auth=debug,server::metadata::auth=debug
+
+# Watch dashboard authz
+RUST_LOG=server::dashboard=debug
+
+# JSON output for CloudWatch
+GUARDIAN_LOG_FORMAT=json RUST_LOG=info cargo run -p guardian-server
+```
+
+### Startup configuration banner
+
+At boot, before any listener binds, the server emits a one-shot summary
+of its resolved, non-secret configuration (target
+`server::builder::startup`). Use it to confirm which version, backends,
+network, and signers a process is actually running:
+
+```
+===== Guardian server configuration =====
+Guardian server starting version="0.1.0" git_sha="" profile="release"
+network network=MidenTestnet rpc_endpoint="https://rpc.testnet.miden.io"
+storage backend storage=Postgres
+ack signers falcon="enabled" falcon_commitment=0x… ecdsa_backend="aws-kms" ecdsa_commitment=0x…
+dashboard operators=0 cursor_secret="ephemeral"
+canonicalization check_interval_seconds=10 fast_promotion_enabled=true fast_promotion_interval_seconds=3 fast_promotion_window_seconds=30 max_retries=48 submission_grace_period_seconds=600
+listeners http=3000 grpc=50051
+compiled features features=["postgres"]
+=========================================
+```
+
+Notes:
+
+- Only backend **kinds** and ports appear — never connection strings,
+ KMS key ids, keystore paths, or credentials.
+- `git_sha="unknown"` means the build received no `GUARDIAN_GIT_SHA`
+ build arg and had no git working tree — expected for some Docker
+ builds; not an error.
+- `cursor_secret="ephemeral"` corroborates the separate
+ `GUARDIAN_DASHBOARD_CURSOR_SECRET` warning: multi-replica deployments
+ must set a stable shared secret (see
+ [`CONFIGURATION.md`](../reference/configuration.md)).
+- `canonicalization` absent (replaced by an "optimistic mode" line)
+ means deltas are accepted without on-chain verification.
+
+In ECS, container logs flow to the CloudWatch log group named
+`/ecs/-server` ([`infra/data.tf:88`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf#L88)). Use ECS Exec
+to attach to a live task when needed:
+
+```bash
+aws ecs execute-command --cluster -cluster \
+ --task --container -server \
+ --interactive --command "/bin/sh"
+```
+
+ECS Exec requires the task role's `ssmmessages:*` actions
+([`infra/iam.tf:115`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/iam.tf#L115)) — already granted by default.
+
+### What an operator should watch
+
+- **`candidate` deltas exceeding the canonicalization grace period** —
+ indicates Miden submission isn't happening.
+- **`discarded` delta rate** — small numbers are normal (race conditions);
+ spikes mean RPC trouble or wrong network targeting.
+- **`retained` delta count** — a persistently non-zero gauge means
+ give-ups are outpacing reconciliation; check Miden RPC health and the
+ `reconcile_*` outcomes above.
+- **`rpc_unavailable` / `rpc_validation_failed` rates** — Miden node
+ health.
+- **`storage_error` rate** — DB or filesystem trouble.
+- **`authentication_failed` rate** — sudden spike usually means a client
+ clock drift event or an attacker probing.
+- **ACK pubkey on `GET /pubkey`** — should not change unless you rotated.
+
+There are no Terraform-managed dashboards or alarms yet — building these
+out remains an open production-hardening item.
+
+## When all else fails
+
+1. Capture the server logs around the failing request (timestamps,
+ request IDs, error codes).
+2. Capture the client SDK version and the request envelope it built.
+3. Compare against Miden directly — if Miden agrees with the client and
+ Guardian disagrees, the operator probably has a stale or corrupted
+ backend.
+4. Open an issue at [https://github.com/OpenZeppelin/guardian/issues](https://github.com/OpenZeppelin/guardian/issues)
+ with the request ID, error code, and log excerpt.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/reference/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/reference/_category_.json
new file mode 100644
index 00000000..1b337d5a
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/reference/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Reference",
+ "position": 5
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/reference/configuration.md b/versioned_docs/version-0.16/builder/miden-guardian/reference/configuration.md
new file mode 100644
index 00000000..babd4176
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/reference/configuration.md
@@ -0,0 +1,394 @@
+---
+title: "Configuration Reference"
+sidebar_position: 1
+---
+Every environment variable Guardian honours, in one place.
+
+Use this as a lookup table — pair it with [`LOCAL_DEV.md`](../getting-started/local-dev.md)
+for which combinations make sense locally and
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md) for how the deploy script
+sets them in production.
+
+For Terraform variables (everything under `infra/`), see
+[`infra/README.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/README.md#variables-reference) — those are a
+separate surface; the deploy script translates between Terraform vars and
+the runtime env vars in this document.
+
+## Conventions
+
+- **Required** means the server will refuse to start if the variable is
+ missing in the relevant build/feature combo.
+- **Default** is what the server picks when the variable is unset.
+- **Build mode** indicates which Cargo feature gate consumes the value.
+ Defaults builds are the no-feature filesystem build unless stated.
+
+## Runtime — server identity and storage
+
+| Variable | Default | Build mode | Notes |
+|---|---|---|---|
+| `DATABASE_URL` | _required_ | `postgres` | Postgres connection string. Server panics at startup if unset under `--features postgres`. TLS verification is controlled by the standard `sslmode`/`sslrootcert` parameters — see [Database TLS](#database-tls). |
+| `GUARDIAN_STORAGE_PATH` | `/var/guardian/storage` | filesystem | Path for state + delta blobs. |
+| `GUARDIAN_METADATA_PATH` | `/var/guardian/metadata` | filesystem | Path for accounts, auth credentials, network config. Holds `.metadata/accounts.json` (account records) and `.metadata/auth_state.json` (replay-protection timestamps). Back up and restore the two files together: the server refuses to start if `auth_state.json` is missing while migrated accounts exist, because empty replay state would re-accept previously seen requests (see [Troubleshooting](../operations/troubleshooting.md#server-fails-to-start)). |
+| `GUARDIAN_KEYSTORE_PATH` | `/var/guardian/keystore` | any | Local Falcon/ECDSA key files (ACK signers and per-account creds). |
+| `GUARDIAN_DB_POOL_MAX_SIZE` | `16` (code default); `32` set by the prod Terraform profile | `postgres` | Storage backend pool size. |
+| `GUARDIAN_METADATA_DB_POOL_MAX_SIZE` | matches storage | `postgres` | Metadata backend pool size; usually leave equal. |
+| `GUARDIAN_CANONICALIZATION_FAST_PROMOTION_ENABLED` | `true` | any | Enables the additional promotion-only pass for recent candidates. Set to `false` to use only the full canonicalization interval; AWS deployments can set Terraform variable `guardian_canonicalization_fast_promotion_enabled = false`. |
+| `GUARDIAN_CANONICALIZATION_MAX_CONCURRENT_ACCOUNTS` | `10` (code default); `50` set by the prod Terraform profile | any | Accounts one canonicalization pass processes in parallel; `1` = fully sequential. Each account holds a DB connection only during its short fenced transactions — the dominant cost is a connectionless chain RPC — so this may exceed `GUARDIAN_DB_POOL_MAX_SIZE`; simultaneous write bursts just queue briefly at the pool. |
+| `GUARDIAN_SERVER_FEATURES` | _build-time_ | deploy script | Comma list (`postgres`, `evm`) the deploy script compiles in. Not read at runtime — controls how the image is built. |
+
+Canonicalization settings apply as follows:
+
+| Setting | Full pass | Fast promotion |
+|---|---|---|
+| `check_interval_seconds` | Sets the full-pass cadence. | Stops new fast work when the next full pass is due. |
+| `fast_promotion_enabled` | No effect. | Enables or disables the pass. |
+| `fast_promotion_interval_seconds` | No effect. | Sets its cadence and admission window. Work already in flight may finish after the deadline. |
+| `fast_promotion_window_seconds` | No effect. | Limits eligibility to recently created candidates. |
+| `max_retries` | Controls when repeated verification failures discard a candidate. | Not read or modified. |
+| `submission_grace_period_seconds` | Defers retry consumption for young candidates. | Not read or modified. |
+| `divergence_confirmations` | Controls confirmed-divergence discard decisions. | Not read or modified. |
+| `max_concurrent_accounts` | Bounds concurrent accounts. | Bounds concurrent accounts. Candidates remain sequential within each account. |
+
+Fast promotion is promotion-only: a miss, absent account, RPC failure, invalid
+claim, or reconstruction mismatch does not change candidate status, retry count,
+or divergence count. The full pass remains responsible for every retry, defer,
+divergence, and discard decision.
+
+### Database TLS
+
+TLS behavior is driven entirely by the standard libpq parameters in
+`DATABASE_URL`; there is no Guardian-specific TLS env var. To migrate a deployed
+AWS stack to verified TLS, see
+[`runbooks/enable-db-tls.md`](../runbooks/enable-db-tls.md). The same parameters
+govern both the synchronous startup-migration connection and the asynchronous
+runtime pools, so they always behave identically.
+
+| `sslmode` | `sslrootcert` | Behavior |
+|---|---|---|
+| _omitted_ / `disable` | _(any)_ | Plaintext, no TLS. |
+| `require` | _(none)_ | Encrypted, certificate **not** verified. |
+| `require` | `` | Encrypted + certificate chain verified (promoted to `verify-ca`, matching libpq). |
+| `verify-ca` | `` | Encrypted + chain verified (hostname not checked). |
+| `verify-full` | `` | Encrypted + chain **and** hostname verified. Recommended for managed providers. |
+
+- `sslrootcert=` points at a PEM CA bundle file the server can read; the
+ whole bundle must parse (a malformed entry fails startup). It may contain
+ multiple roots.
+- Verifying modes **fail closed**: a missing/unreadable/empty CA bundle, an
+ unknown `sslmode`, `allow`/`prefer` (which permit plaintext fallback), or
+ `sslrootcert=system` (unsupported — needs libpq ≥16) all abort startup with an
+ actionable error rather than connecting insecurely.
+- Hostname matching under `verify-full` is strict SAN-based; certificates without
+ a matching Subject Alternative Name (including Common-Name-only certs) are
+ rejected.
+
+Per-provider examples:
+
+```text
+# AWS RDS (managed; recommended). Mount a combined bundle of the Amazon RDS CA
+# roots AND the Amazon Trust Services roots — the RDS Proxy presents an ACM
+# certificate chaining to Amazon Trust Services, a direct instance chains to the
+# RDS CA roots.
+DATABASE_URL=postgres://USER:PW@HOST:5432/guardian?sslmode=verify-full&sslrootcert=/etc/guardian/tls/rds-combined-ca.pem
+
+# Another managed provider (GCP Cloud SQL / Azure / Supabase / Neon …)
+DATABASE_URL=postgres://USER:PW@HOST:5432/guardian?sslmode=verify-full&sslrootcert=/etc/guardian/tls/provider-ca.pem
+
+# Local docker compose (no TLS)
+DATABASE_URL=postgres://guardian:guardian@localhost:5432/guardian
+```
+
+## Runtime — ACK signing and network
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_ENV` | _unset_ | Selects the **default** ACK secret source: `prod` → AWS Secrets Manager; anything else (or unset) → ephemeral filesystem keys, regenerated each restart. Override explicitly with `GUARDIAN_ACK_SECRET_PROVIDER` (below) — e.g. `file` for a stable identity without AWS. |
+| `AWS_REGION` | _unset_ | **Required** when `GUARDIAN_ENV=prod`. Region for Secrets Manager calls. |
+| `GUARDIAN_NETWORK_TYPE` | _none — **required**_ | Miden network identifier: `MidenLocal` (`local`), `MidenTestnet` (`testnet`), `MidenDevnet` (`devnet`); case-insensitive. Pins the network *identity* (bech32 address prefixes, dashboard rendering) and the default Miden RPC endpoint. The server refuses to start when it is unset or unrecognized — there is no fallback network. |
+| `GUARDIAN_MIDEN_RPC_ENDPOINT` | per-network default | Overrides the Miden node RPC endpoint (self-hosted node, private RPC, sidecar container) without changing network identity. Must be an origin-only `http(s)` URL (`scheme://host[:port]`); userinfo, non-root paths, queries, and fragments are rejected because tonic does not send them as authentication. An invalid value fails startup. Startup logs the validated origin. Combining an override with `MidenTestnet`/`MidenDevnet` logs a warning (legitimate for a mirror). A configured endpoint never falls back to the network default. |
+| `GUARDIAN_MIDEN_RPC_TIMEOUT_MS` | `10000` | Per-request deadline on the node channel (the same 10s default every Miden RPC surface uses). Positive integer; `0` or a malformed value fails startup. |
+| `GUARDIAN_MIDEN_RPC_MAX_ATTEMPTS` | `1` (retries off) | Attempt budget for eligible idempotent node reads **outside canonicalization**. Canonicalization performs exactly one node read per observation regardless of this value — transient failures there are recovered by the next scheduled pass, so raising this budget never makes a canonicalization pass hold its lease longer. Transaction submission is never retried regardless of this value. Endpoint failover is not currently implemented. Retry activity is visible as `guardian_miden_rpc_retries_total` (never incremented by canonicalization reads). |
+
+> **Upgrade note:** this release reduces the default Miden RPC deadline from 30
+> seconds to 10 seconds for existing deployments. The deadline is applied at
+> the channel level, so it caps submissions as well as reads even though
+> submissions remain single-attempt. Set `GUARDIAN_MIDEN_RPC_TIMEOUT_MS=30000`
+> to retain the previous deadline.
+
+ACK secret IDs are configurable. The server reads two env vars at startup
+and falls back to fixed defaults when they're unset
+([`crates/server/src/ack/secrets_manager.rs:10-13`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/secrets_manager.rs#L10)):
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_ACK_FALCON_SECRET_ID` | `guardian-prod/server/ack-falcon-secret-key` | Secrets Manager name/ARN for the Falcon ACK secret key. |
+| `GUARDIAN_ACK_ECDSA_SECRET_ID` | `guardian-prod/server/ack-ecdsa-secret-key` | Secrets Manager name/ARN for the ECDSA ACK secret key. Used only when the ECDSA backend is `in-memory`. |
+
+### ACK secret provider (stable identity without AWS)
+
+Outside prod the default (`GUARDIAN_ACK_SECRET_PROVIDER=none`) generates a fresh
+ACK keypair on every restart, which changes the Guardian's on-chain ack-key
+commitment and freezes accounts that pinned the old one. Set the provider to
+`file` to load fixed keys from local files instead — a stable identity without
+AWS Secrets Manager. Each file holds the hex string emitted by `ack-keygen`
+(identical to what Secrets Manager stores). See the
+[Secrets runbook](../runbooks/secrets.md#self-hosted-stable-identity-without-aws).
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_ACK_SECRET_PROVIDER` | `aws` when `GUARDIAN_ENV=prod`, else `none` | Source of the ACK signing keys: `aws` (Secrets Manager), `file` (local files), or `none` (ephemeral, dev only). An unrecognized value fails startup; `none` is rejected when `GUARDIAN_ENV=prod`. |
+| `GUARDIAN_ACK_FALCON_SECRET_PATH` | _unset_ | **Required** when `GUARDIAN_ACK_SECRET_PROVIDER=file`. Path to a file holding the hex-encoded Falcon ACK secret key. On Unix the file must be owner-only (mode `0600`) or startup fails. |
+| `GUARDIAN_ACK_ECDSA_SECRET_PATH` | _unset_ | **Required** when `GUARDIAN_ACK_SECRET_PROVIDER=file`, **unless** `GUARDIAN_ACK_ECDSA_BACKEND=aws-kms` (then the ECDSA key comes from KMS and this file is never read). Path to the hex-encoded ECDSA ACK secret key; same `0600` requirement. |
+
+### Storage encryption at rest
+
+Application-layer encryption of the sensitive stored payloads (account state,
+delta and proposal payloads). It is **opt-in by key-source presence** — configure
+a key and the server encrypts; configure none and it stores plaintext exactly as
+before. Routing/index fields (account id, nonce, commitments, status, timestamps)
+always stay plaintext.
+
+**Which variable do I set?** Choose **one key source** — the dev key for local
+work, or the Secrets Manager secret for production. You never set both (doing so
+is a startup error). `GUARDIAN_STORAGE_ENCRYPTION_KEY_ID` is **not** a key — it is
+an optional label, and most users leave it unset.
+
+**Key sources** (set exactly one; presence is what turns encryption on):
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_STORAGE_ENCRYPTION_KEY` | _unset_ | **Dev key source.** The actual 32-byte key, base64-encoded (`openssl rand -base64 32`). |
+| `GUARDIAN_STORAGE_ENCRYPTION_KEY_SECRET_ID` | _unset_ | **Prod key source.** AWS Secrets Manager name/ARN of a secret holding a structured `{ "active": kid, "keys": { kid: base64-32-bytes } }` document (one or more keys). Reuses `AWS_REGION`. |
+
+**Optional label:**
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_STORAGE_ENCRYPTION_KEY_ID` | `k1` | Only used with the dev key source. Sets the key id (`kid`) recorded on each encrypted record so the key can be identified later. The default is fine for almost everyone — leave it unset unless you specifically need the dev key's id to match an id used elsewhere. (With Secrets Manager the ids come from the secret's `keys`/`active` fields instead, so this variable is ignored.) |
+
+Every encrypted record stores the `kid` of the key that wrote it, and on read the
+key is resolved by that id — this is what lets Secrets Manager hold several keys
+and rotate them (add a new key, repoint `active`, keep the old key so existing
+records still decrypt). The dev key source holds a single key, so it cannot do
+multi-key rotation reads; that is a production capability.
+
+Rules: configure **exactly one** key source (both set → startup error). When a key
+source is set the server validates it at startup and fails fast on a
+missing/malformed/wrong-length key — it never silently falls back to plaintext.
+Encryption is fixed for a populated store: the server records a marker on the
+first encrypted write and refuses to mix plaintext and ciphertext, so enable it
+against an empty store (e.g. after the Miden 0.16 reset, see
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md#data-resets)). Switching an existing
+store requires an explicit re-encryption migration (not yet provided).
+
+### Hosted ECDSA signer backend
+
+The ECDSA ACK signer can keep its private key outside the process in a hosted
+backend. Falcon is unaffected and always uses the in-memory path.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_ACK_ECDSA_BACKEND` | `in-memory` | `in-memory` (filesystem keystore, or Secrets Manager when `GUARDIAN_ENV=prod`) or `aws-kms`. An unrecognized value fails startup listing the supported values. |
+| `GUARDIAN_ACK_ECDSA_KMS_KEY_ID` | _unset_ | **Required** when `GUARDIAN_ACK_ECDSA_BACKEND=aws-kms`. KMS key id, ARN, or alias. The key must be `ECC_SECG_P256K1` with usage `SIGN_VERIFY`. |
+
+With `aws-kms`, the server holds only the key handle; the private key never enters
+the process. At startup it fetches the public key, validates the key spec, and
+performs a sign probe to confirm `kms:Sign` permission — failing fast otherwise.
+The ECDSA secret in Secrets Manager (`GUARDIAN_ACK_ECDSA_SECRET_ID`) is **not**
+read on this path. Credentials resolve through the standard AWS chain (the ECS
+task role in production); required IAM is `kms:GetPublicKey` and `kms:Sign` on the
+key.
+
+> Switching an existing deployment from `in-memory` to `aws-kms` means a new
+> keypair, hence a new ECDSA `pubkey`/`commitment`. Re-establish downstream trust
+> accordingly.
+
+> **`_SECRET_ID` (runtime) vs `_SECRET_NAME` (deploy-side):** the server
+> reads `GUARDIAN_ACK_*_SECRET_ID` at startup, but you typically don't
+> set these by hand. The deploy script accepts
+> `GUARDIAN_ACK_*_SECRET_NAME` (see the [Deploy script
+> section](#deploy-script-scriptsaws-deploysh) below), passes it into
+> Terraform as `guardian_ack_*_secret_name`, and Terraform sets the
+> matching `_SECRET_ID` env var on the ECS task. Same value, three
+> places — see the [Secrets runbook](../runbooks/secrets.md#ack-signing-keys)
+> for the full override chain.
+
+In the reference AWS deploy, Terraform sets both `_SECRET_ID` env vars on
+the ECS task to `${stack_name}/server/ack-{falcon,ecdsa}-secret-key` so
+multi-stack deployments get scoped IDs.
+
+## Runtime — request safety
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_RATE_LIMIT_ENABLED` | `true` | Master kill-switch for rate limiting on **both** transports (HTTP and gRPC; there is no per-transport toggle). Set `false` only in test environments. Client identity for keying comes from the ingress (rightmost `X-Forwarded-For` entry, then `X-Real-IP`, then the socket peer); deployments not behind the reference ALB must forward the client address on both listeners, strip any client-supplied `X-Forwarded-For` if they identify callers with `X-Real-IP`, and restrict direct access to the server ports; see [PRODUCTION.md](../operations/production.md#running-behind-your-own-ingress-non-aws). |
+| `GUARDIAN_RATE_BURST_PER_SEC` | `10` (code default); `200` set by the prod Terraform profile | Requests allowed in any one-second window, keyed per IP **and** endpoint, where the endpoint is the HTTP path or the gRPC method. Both transports are metered from one store, but because their endpoint names differ, a burst bucket is never shared across transports. |
+| `GUARDIAN_RATE_PER_MIN` | `60` (code default); `5000` set by the prod Terraform profile | Sustained rate, keyed per IP only, so HTTP and gRPC calls from one client draw on the same allowance. This is the cross-transport limit: deployments sized for HTTP-only traffic should re-check it, since gRPC traffic (the Rust SDK's default transport) counts against it since the transport-bypass fix. |
+| `GUARDIAN_MAX_REPLICAS` | `1` (code default); greater of desired count and autoscaling max when enabled, or desired count otherwise, set by Terraform | Per-replica rate-limit divisor used by global HTTP+gRPC and dashboard per-commitment limits. Each replica enforces `global / GUARDIAN_MAX_REPLICAS`, keeping the aggregate at or below the configured limit through steady-state autoscaling. During a rolling deployment the temporary aggregate may rise by up to `deployment_maximum_percent / 100` (2× by default). Drives rate-limiting only — coordination mode is backend-derived. Running below the steady-state maximum over-throttles; HTTP keep-alive can pin a client to one replica. An override is clamped up to steady-state capacity by Terraform. Must be a positive integer when set: an invalid value **fails startup in prod** and is treated as `1` with a warning elsewhere. See [`runbooks/horizontal-scaling.md`](../runbooks/horizontal-scaling.md). |
+| `GUARDIAN_DASHBOARD_COMMITMENT_RATE_BURST_PER_SEC` | `6` | Fleet-wide dashboard challenge and verification burst budget for one operator commitment. Divided by `GUARDIAN_MAX_REPLICAS` and clamped to at least 1 per replica. A custom value below the divisor can therefore exceed its nominal fleet-wide budget. |
+| `GUARDIAN_DASHBOARD_COMMITMENT_RATE_PER_MIN` | `30` | Fleet-wide dashboard challenge and verification sustained budget for one operator commitment. Divided by `GUARDIAN_MAX_REPLICAS` and clamped to at least 1 per replica. A custom value below the divisor can therefore exceed its nominal fleet-wide budget. |
+| `GUARDIAN_MAX_REQUEST_BYTES` | `1048576` (1 MB) | Reject request bodies larger than this. |
+| `GUARDIAN_MAX_PENDING_PROPOSALS_PER_ACCOUNT` | `20` | Account-level cap; hitting it returns `pending_proposals_limit`. |
+| `GUARDIAN_CORS_ALLOWED_ORIGINS` | _unset_ | Comma-separated explicit origins. **Unset → permissive `Any` origin / `Any` methods / `Any` headers, credentials disabled** (suitable for local dev). **Set → strict allowlist with `allow_credentials(true)`** (required for production browser clients). |
+
+## Runtime — metrics (Prometheus)
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_METRICS_ENABLED` | `false` | Master switch for the Prometheus integration. When `false` (default) nothing runs: no metrics listener, no recorder, no storage instrumentation, no background refresher. |
+| `GUARDIAN_METRICS_ADDR` | `127.0.0.1:9464` | Bind address of the **dedicated** metrics listener (separate from the API port). Loopback by default; set `0.0.0.0:9464` in containers so a Prometheus sidecar/agent can reach it. Invalid values fall back to the default with a warning. |
+| `GUARDIAN_METRICS_PATH` | `/metrics` | Path serving the Prometheus text exposition on that listener. Must start with `/`. |
+| `GUARDIAN_METRICS_REFRESH_INTERVAL_SECS` | `30` | Cadence of the background task that refreshes slow aggregate gauges (delta status counts, in-flight proposals, account count). Scrapes never query storage directly. |
+| `GUARDIAN_METRICS_BEARER_TOKEN` | _unset_ | Optional shared-secret scrape token. When set, scrapes must send `Authorization: Bearer ` (Prometheus `authorization.credentials` in the scrape config); anything else gets `401`. Compared in constant time, held as a non-loggable secret wrapper in process. |
+
+The metrics endpoint is intentionally **not** part of the main API
+router: it bypasses rate limiting and CORS and is protected by network
+isolation first (loopback default / private network / security group),
+the bearer token second, and proxy-terminated TLS where transport
+encryption is required. Never expose it to a public network. The
+exposed metric taxonomy and cardinality rules are documented in
+[`spec/api.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/api.md); see the
+[Observability guide](../guides/observability.md) for scraping and a
+one-command Grafana dashboard stack.
+
+## Runtime — dashboard
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ID` | _unset_ | AWS Secrets Manager secret name/ARN holding the operator allowlist JSON. Hot-reloaded on every challenge and authenticated `/dashboard/*` request. |
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE` | _unset_ | Local JSON path for the same payload. Local dev only. |
+| `GUARDIAN_DASHBOARD_CURSOR_SECRET` | random per process if unset | 32-byte hex HMAC key for dashboard pagination cursors. Pin a shared value across replicas so cursors validate everywhere. The prod Terraform profile injects a pre-created Secrets Manager value into every task. If unset outside that profile, the server **warns** and generates an ephemeral per-process key and still boots (in every stage); an ephemeral key breaks pagination cursors across replicas and restarts — the dashboard feeds and the client `GET /delta/history` endpoint — nothing else, so it is not a startup guard. |
+
+`GET /dashboard/info.environment` is derived from `GUARDIAN_NETWORK_TYPE`
+(`testnet`, `devnet`, or `local`) rather than configured separately.
+
+### Prod-stage startup guards & HA behavior
+
+When `GUARDIAN_ENV=prod`, the server fails fast on misconfigurations that are
+silently broken across replicas:
+
+- the **filesystem** storage backend is refused (single-instance only — use the
+ Postgres image with `DATABASE_URL`);
+- a rate limit that partitions to **0 requests per replica** is refused — i.e.
+ the global `GUARDIAN_RATE_BURST_PER_SEC`/`GUARDIAN_RATE_PER_MIN` is below
+ `GUARDIAN_MAX_REPLICAS`, which would make every replica throttle all traffic.
+ Raise the global limit or lower `GUARDIAN_MAX_REPLICAS`. (Non-prod only warns.)
+
+On the Postgres backend, operator/EVM sessions, login challenges, and the
+canonicalization lease are shared across replicas (backend-derived — no tunable
+disables this). If the database is briefly unavailable, authentication **fails
+closed** (rejected, never bypassed) and recovers automatically. See the
+[horizontal-scaling runbook](../runbooks/horizontal-scaling.md).
+
+Allowlist payload shapes and enrollment flow:
+[`docs/DASHBOARD.md`](../operations/dashboard.md).
+
+## Runtime — EVM (feature-gated)
+
+These take effect only when the server is built with `--features evm`.
+The server reads only the two variables in this table; the allowed chain
+set is **derived from the keys of `GUARDIAN_EVM_RPC_URLS`** rather than a
+separate variable.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `GUARDIAN_EVM_RPC_URLS` | _unset_ (treated as an empty registry) | Comma list `chain_id=rpc_url`. E.g. `1=https://…,11155111=https://…`. Allowed chain IDs are the keys of this map. **Required for usable EVM chains** — when unset, the server starts but the EVM registry is empty and every chain ID will be rejected. |
+| `GUARDIAN_EVM_ENTRYPOINT_ADDRESS` | `0x433709009b8330fda32311df1c2afa402ed8d009` (EntryPoint v0.9) | Shared EntryPoint address used for finality checks across chains. |
+
+## Logging
+
+| Variable | Default | Notes |
+|---|---|---|
+| `RUST_LOG` | `info` | Standard `tracing-subscriber` filter. Module-scoped filters work: `RUST_LOG=server::jobs::canonicalization=debug`. Hot-path request *events* (`get_state`, `get_delta`, `push_delta`, proposal create/sign, `lookup_account`) are `debug`; use `RUST_LOG=server=debug` or `RUST_LOG=server::services=debug` to see them. At `info` each request instead emits a single span-close line carrying the span's fields (account ID, nonce, commitment, signer/match counts) plus `time.busy` / `time.idle`. That line is emitted on the success *and* the error path, which is what correlates the centralized 5xx log (see below) back to an account. |
+| `GUARDIAN_LOG_FORMAT` | `text` | Log output format. `text` — human-readable (ANSI when TTY, plain otherwise). `json` — flattened JSON with span context for CloudWatch Logs Insights. `compact` — single-line text. Value is trimmed and case-insensitive; unknown values fall back to `text` with a stderr warn (emitted before the tracing subscriber is installed). Defaults to `text` when unset. ECS default is `json` (see `infra/variables.tf` `guardian_log_format`). |
+
+The 5xx and 4xx lines emitted from `GuardianError`'s HTTP `IntoResponse` and
+`tonic::Status` conversions run *after* the service span has closed, so they
+carry only `code` and `detail`, never account fields. The preceding span-close
+line is what supplies the account context for them.
+
+Useful filters during debugging — see
+[`TROUBLESHOOTING.md`](../operations/troubleshooting.md#logging-and-observability).
+
+## Deploy script (`scripts/aws-deploy.sh`)
+
+These are read by the deploy script, not by the server itself. The script
+turns them into Terraform variables or build-time choices.
+
+| Variable | Default | Notes |
+|---|---|---|
+| `STACK_NAME` | `guardian` | Base name for all AWS resources and Terraform state file. |
+| `DEPLOY_STAGE` | `dev` | `dev` or `prod`; selects stage profile (autoscaling, RDS Proxy, etc.). |
+| `CPU_ARCHITECTURE` | `X86_64` | `X86_64` or `ARM64`. Picks the Docker buildx platform and the ECS task arch. |
+| `AWS_REGION` | _required_ | All AWS API calls. |
+| `DOMAIN_NAME` | `openzeppelin.com` | Root domain for the canonical public hostname. |
+| `SUBDOMAIN` | `guardian` | Host portion of the public hostname. |
+| `ACM_CERTIFICATE_ARN` | _unset_ | ACM certificate for HTTPS on the canonical hostname. |
+| `ROUTE53_ZONE_ID` | _unset_ | Optional Route 53 hosted zone for an alias record. |
+| `CLOUDFLARE_ZONE_ID` | _unset_ | Optional Cloudflare zone for CNAME management. |
+| `CLOUDFLARE_API_TOKEN` | _unset_ | Required when either primary or secondary Cloudflare DNS management is enabled. |
+| `CLOUDFLARE_PROXIED` | `true` | Whether the Cloudflare CNAME should be proxied. |
+| `ALIAS_SUBDOMAIN` | _unset_ | Migration-only legacy subdomain under `DOMAIN_NAME`; leave unset for normal deployments. DNS may be Terraform-managed or external. |
+| `ALIAS_ACM_CERTIFICATE_ARN` | `ACM_CERTIFICATE_ARN` | Migration-only distinct certificate for the legacy hostname, attached through SNI when needed. |
+| `GUARDIAN_ACK_FALCON_SECRET_NAME` | _unset_ → `${STACK_NAME}/server/ack-falcon-secret-key` | Deploy-side override for the Falcon ACK secret. Passed into Terraform as `guardian_ack_falcon_secret_name` and set on the ECS task as the runtime `GUARDIAN_ACK_FALCON_SECRET_ID`. |
+| `GUARDIAN_ACK_ECDSA_SECRET_NAME` | _unset_ → `${STACK_NAME}/server/ack-ecdsa-secret-key` | Deploy-side override for the ECDSA ACK secret. Same flow as the Falcon entry above. |
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON` | _unset_ | Inline JSON array of operator pubkeys; Terraform creates the secret from this. Mutually exclusive with `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN`. |
+| `GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN` | _unset_ | ARN of an externally-managed operator pubkeys secret. When set, Terraform does not create one and the task reads from this ARN instead. |
+| `GUARDIAN_EVM_CHAIN_CONFIG_FILE` | _unset_ | Path to a JSON file the deploy script reads to derive `GUARDIAN_EVM_RPC_URLS` (and the bookkeeping `GUARDIAN_EVM_ALLOWED_CHAIN_IDS` Terraform variable). Not read by the server. |
+| `GUARDIAN_EVM_ALLOWED_CHAIN_IDS` | _unset_ | Comma list of chain IDs used **only by Terraform** for bookkeeping / secret naming. The server itself derives allowed chains from `GUARDIAN_EVM_RPC_URLS` keys. |
+| `GUARDIAN_EVM_RPC_URLS_SECRET_ARN` | _unset_ | ECS-injection only: when set, the ECS task reads `GUARDIAN_EVM_RPC_URLS` from this Secrets Manager ARN at task start (the server still sees a plain env var). |
+| `GUARDIAN_EVM_ALLOWED_CHAIN_IDS_SECRET_ARN` | _unset_ | Same, for the bookkeeping chain-ID list. |
+| `TF_VAR_*` | _unset_ | Any standard Terraform var override; the script passes through. |
+
+## Secrets in env vars — scope of in-process protection
+
+Secret-bearing env vars (`DATABASE_URL`, `GUARDIAN_DASHBOARD_CURSOR_SECRET`,
+`GUARDIAN_EVM_RPC_URLS`) are wrapped at the point of read into
+zero-on-drop, no-`Display`/no-`Serialize` types inside the server
+process, so they cannot accidentally appear in logs, panic messages, or
+serialized responses. **This does not protect the OS process
+environment block** — `/proc//environ`, coredumps, fork-inherited
+env, and ECS task-definition `environment` fields all remain visible at
+the OS layer regardless. For the highest-sensitivity material (ACK
+signing keys) prefer the AWS Secrets Manager runtime-fetch path
+(`GUARDIAN_ACK_FALCON_SECRET_ID` / `GUARDIAN_ACK_ECDSA_SECRET_ID`),
+which is already how production loads those keys.
+
+## What's _not_ env-configurable
+
+A few things are deliberately compile-time or builder-API only — knowing
+this saves you from grepping:
+
+- **HTTP / gRPC ports.** `3000` and `50051` are builder defaults
+ ([`builder/mod.rs:68`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/builder/mod.rs#L68));
+ configurable through the Rust builder but not via env. ECS pins these
+ in the task definition.
+- **Storage backend choice.** Cargo feature `postgres` (or its absence),
+ not an env var. See
+ [Storage modes](../architecture/services.md#storage-modes).
+- **EVM support.** Cargo feature `evm`. If the binary wasn't built with
+ it, no env var will turn it on.
+- **Canonicalization knobs** (`check_interval_seconds`,
+ `fast_promotion_interval_seconds`, `fast_promotion_window_seconds`,
+ `max_retries`, `submission_grace_period_seconds`,
+ `divergence_confirmations`). Currently hard-coded in the canonicalization
+ worker; require a code change to alter. The exceptions are
+ `fast_promotion_enabled` and `max_concurrent_accounts`, configurable via
+ `GUARDIAN_CANONICALIZATION_FAST_PROMOTION_ENABLED` and
+ `GUARDIAN_CANONICALIZATION_MAX_CONCURRENT_ACCOUNTS` (see the
+ environment table above).
+- **Auth timestamp window.** `MAX_TIMESTAMP_SKEW_MS = 300_000` (5 min) is
+ hard-coded in
+ [`metadata/auth/credentials.rs:6`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/metadata/auth/credentials.rs#L6).
+
+## Quick combos
+
+| I want… | Set |
+|---|---|
+| Minimum local dev | _nothing_ — `docker compose up` works |
+| Postgres backend locally | `DATABASE_URL=…` + build with `--features postgres` |
+| EVM support locally | `GUARDIAN_EVM_RPC_URLS` (allowed chain set derives from its keys) + build with `--features evm` |
+| Use Secrets Manager for ACK keys | `GUARDIAN_ENV=prod` + `AWS_REGION=` + secrets pre-created |
+| Run the dashboard locally | `GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE=/path/to/allowlist.json` |
+| Multi-replica (HA) | Postgres backend + `GUARDIAN_DASHBOARD_CURSOR_SECRET=<64 hex>` pinned across tasks + `GUARDIAN_MAX_REPLICAS=`. The prod Terraform profile sets these after `bootstrap-dashboard-cursor-secret` creates the required Secrets Manager entry. |
+| Higher throughput in prod | `GUARDIAN_RATE_BURST_PER_SEC`, `GUARDIAN_RATE_PER_MIN`, `GUARDIAN_DB_POOL_MAX_SIZE`, `GUARDIAN_CANONICALIZATION_MAX_CONCURRENT_ACCOUNTS` |
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/reference/multisig-sdk.md b/versioned_docs/version-0.16/builder/miden-guardian/reference/multisig-sdk.md
new file mode 100644
index 00000000..9b44801e
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/reference/multisig-sdk.md
@@ -0,0 +1,1996 @@
+---
+title: "Miden Multisig SDK"
+sidebar_position: 3
+---
+An SDK for creating and managing multisignature accounts on the Miden network. Available for both **TypeScript** (web/browser) and **Rust** (native/server) environments.
+
+> New to Guardian? Read [`docs/CONCEPTS.md`](../getting-started/concepts.md) for the trust
+> model and state/delta lifecycle, and
+> [`docs/architecture/services.md`](../architecture/services.md) for the
+> server-side surface this SDK targets.
+
+## Table of Contents
+
+- [Quick Start](#quick-start)
+- [Core Concepts](#core-concepts)
+- [TypeScript SDK Guide](#typescript-sdk-guide)
+- [Rust SDK Guide](#rust-sdk-guide)
+- [Use Cases](#use-cases)
+- [Offline Workflow](#offline-workflow)
+- [Releasing](#releasing)
+
+---
+
+## Quick Start
+
+### Installation
+
+The multisig sdk has as peer dependency on the miden-sdk, you will need to install both.
+
+**TypeScript (npm)**
+```bash
+npm install @openzeppelin/miden-multisig-client @miden-sdk/miden-sdk@0.16.0
+```
+
+**Rust (Cargo.toml)**
+```toml
+[dependencies]
+miden-multisig-client = "0.17.0"
+miden-client = "=0.16.0"
+```
+
+### 5-Minute Example
+
+Create a 1-of-3 multisig account, propose a transfer, collect signatures, and execute.
+
+#### TypeScript
+
+```typescript
+import { MidenClient, AuthSecretKey } from '@miden-sdk/miden-sdk';
+import { MultisigClient, FalconSigner } from '@openzeppelin/miden-multisig-client';
+
+// 1. Setup clients
+const midenClient = await MidenClient.createDevnet();
+const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
+const signer = new FalconSigner(secretKey);
+const client = new MultisigClient(midenClient, {
+ guardianEndpoint: 'http://localhost:3000',
+ midenRpcEndpoint: 'https://rpc.devnet.miden.io',
+});
+// Both endpoints are required; construction throws when either is omitted.
+// midenRpcEndpoint must point at the same network as the injected MidenClient.
+
+// 2. Get GUARDIAN server public key
+const guardianCommitment = await client.guardianClient.getPubkey();
+
+// 3. Create 1-of-3 multisig account
+const config = {
+ threshold: 1,
+ signerCommitments: [signer.commitment, cosigner1Commitment, cosigner2Commitment],
+ guardianCommitment,
+};
+const multisig = await client.create(config, signer);
+await multisig.registerOnGuardian();
+
+console.log('Account created:', multisig.accountId);
+
+// 4. Create a transfer proposal
+const proposal = await multisig.createP2idProposal(
+ recipientAccountId,
+ faucetAccountId,
+ 1000n // amount
+);
+
+console.log('Proposal created:', proposal.id);
+
+// 5. Cosigners sign (only one cosigner is needed)
+await multisig.signProposal(proposal.id);
+
+// 6. Execute when threshold is met
+await multisig.executeProposal(proposal.id);
+
+console.log('Transfer executed!');
+```
+
+### Prover endpoint and retry policy
+
+Both multisig SDKs retry only remote transaction proving. The default is two
+total proof attempts. In TypeScript, endpoint-less injected provers, including
+local and callback provers, run once. A custom remote URL overrides the Miden
+client's injected prover.
+
+```typescript
+const client = new MultisigClient(midenClient, {
+ guardianEndpoint: 'http://localhost:3000',
+ midenRpcEndpoint: 'https://rpc.devnet.miden.io',
+ prover: {
+ url: 'https://prover.example',
+ retry: { maxAttempts: 4 },
+ },
+});
+```
+
+```rust
+use miden_multisig_client::{ProverConfig, ProverRetryPolicy};
+
+let prover = ProverConfig::new()
+ .with_url("https://prover.example")?
+ .with_retry_policy(ProverRetryPolicy::new(4));
+
+let client = MultisigClient::builder()
+ .miden_endpoint(Endpoint::devnet())
+ .guardian_endpoint("http://localhost:50051")
+ .account_dir("/tmp/multisig-client")
+ .prover_config(prover)
+ .generate_key()
+ .build()
+ .await?;
+```
+
+URLs are validated during construction and must be absolute HTTP(S) URLs. A
+custom prover never falls back to a default endpoint. Retries cover transient
+proving conditions such as cancellation, deadlines, temporary unavailability,
+capacity exhaustion, HTTP 408/429/502/503/504, I/O timeout, connection reset,
+and broken pipe. Permanent or unrecognized failures return immediately.
+
+Only proving is retried by this policy: transaction execution, GUARDIAN
+coordination, Miden submission, and local application each run once. A larger
+attempt budget can recover from brief failures but does not add prover
+capacity. Idempotent Miden node reads have their own opt-in retry policy — see
+the next section.
+
+### Miden RPC retry policy
+
+Both multisig SDKs retry idempotent Miden node reads under an `rpc` policy
+that mirrors the prover configuration. The default is two total attempts —
+one classified, jittered retry — matching the prover policy's default; an
+explicit `maxAttempts` of 1 opts out. Transaction submission is **never
+retried**, under any configuration: a submission whose outcome is unknown
+could execute twice if re-sent.
+
+Coverage differs by language. In Rust, the policy wraps the node client
+itself, so every node read the SDK or the underlying Miden client issues —
+syncs, account, note, and block lookups — retries under it. The same policy
+also covers the private-note transport: note fetches and stream
+establishment retry, while **note sends are never retried in-call** (the
+client's relay outbox re-sends undelivered notes on later syncs, so an
+in-call resend could deliver twice). Configuring the transport endpoint
+itself is covered in [Note transport endpoint](#note-transport-endpoint).
+Connection failures retry only when the cause is transport-shaped; TLS,
+certificate, and invalid-endpoint problems fail immediately. In TypeScript,
+the policy wraps the reads the SDK issues directly: on-chain account lookups,
+state-commitment verification, and the guardian-switch node sync; syncs you
+perform on the injected Miden client are owned by your application. As part
+of this policy the Rust SDK also disables the Miden client library's internal
+transport-retry loop, which silently retransmitted rate-limited requests —
+**including submissions** — up to four extra times. This explicit policy is
+the only retry layer: reads get one retry by default, submissions never.
+
+```typescript
+const client = new MultisigClient(midenClient, {
+ guardianEndpoint: 'http://localhost:3000',
+ midenRpcEndpoint: 'https://rpc.devnet.miden.io',
+ prover: { retry: { maxAttempts: 4 } },
+ rpc: { retry: { maxAttempts: 3 } },
+});
+```
+
+```rust
+use miden_multisig_client::{RpcConfig, RpcRetryPolicy};
+
+let rpc = RpcConfig::new()
+ .with_timeout_ms(15_000)?
+ .with_retry_policy(RpcRetryPolicy::new(3));
+
+let client = MultisigClient::builder()
+ .miden_endpoint(Endpoint::devnet())
+ .guardian_endpoint("http://localhost:50051")
+ .account_dir("/tmp/multisig-client")
+ .rpc_config(rpc)
+ .generate_key()
+ .build()
+ .await?;
+```
+
+The classifier is shared with the prover policy (same transient/permanent
+partition and backoff), extended with the node's transport renderings: a
+rate-limit rejection (`Too Many Requests!`), and connection failures the node
+reports under an `Unknown` status (`i/o timeout`, `connection error`,
+`transport error`). Permanent failures — invalid arguments, not-found,
+authentication — return immediately without consuming the budget, and once
+the budget is exhausted the final upstream error is returned unchanged. A
+larger budget adds patience against a rate-limited or briefly unreachable
+node; it does not add node capacity.
+
+In Rust, the per-request gRPC deadline defaults to 10 seconds on every SDK
+node connection (presets, custom endpoints, and the direct commitment
+reads); `with_timeout_ms` replaces it uniformly.
+The TypeScript configuration intentionally has **no timeout**: the browser
+WASM RPC client cannot cancel an in-flight request, and a JavaScript-side
+timeout would abandon the call — whose side effects may still land — while
+reporting failure. Retry behavior itself is fixture-verified to be identical
+across both SDKs.
+
+The Guardian server exposes the same policy for its own node reads via
+`GUARDIAN_MIDEN_RPC_ENDPOINT`, `GUARDIAN_MIDEN_RPC_TIMEOUT_MS`, and
+`GUARDIAN_MIDEN_RPC_MAX_ATTEMPTS` — see
+[docs/CONFIGURATION.md](./configuration.md).
+
+### Note transport endpoint
+
+Private notes are relayed through a note transport service that is separate
+from the node RPC. On the testnet and devnet presets both SDKs derive the
+transport endpoint automatically; a custom node endpoint has no derivable
+transport service, so it must be set explicitly — otherwise private-note
+relay is disabled.
+
+In Rust the endpoint lives on the builder, next to the node endpoint:
+
+```rust
+let client = MultisigClient::builder()
+ .miden_endpoint(Endpoint::try_from("https://my-node.internal:57291")?)
+ .note_transport_endpoint("https://my-transport.internal")
+ .guardian_endpoint("http://localhost:50051")
+ .account_dir("/tmp/multisig-client")
+ .generate_key()
+ .build()
+ .await?;
+```
+
+In TypeScript the injected Miden client owns note relay, so the endpoint is
+set when constructing it, not on the `MultisigClient` configuration:
+
+```typescript
+import { MidenClient } from '@miden-sdk/miden-sdk';
+import { MultisigClient } from '@openzeppelin/miden-multisig-client';
+
+const midenClient = await MidenClient.create({
+ rpcUrl: 'https://my-node.internal:57291',
+ noteTransportUrl: 'https://my-transport.internal',
+});
+
+const client = new MultisigClient(midenClient, {
+ guardianEndpoint: 'http://localhost:3000',
+ midenRpcEndpoint: 'https://my-node.internal:57291',
+});
+```
+
+`noteTransportUrl` also accepts the `'testnet'` and `'devnet'` shorthands, so
+a custom node on a public network can reuse that network's public transport
+service. In both SDKs the transport shares the node RPC resilience policy
+described above.
+
+#### Rust
+
+```rust
+use miden_multisig_client::{MultisigClient, TransactionType, Endpoint};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // 1. Setup client
+ let mut client = MultisigClient::builder()
+ // the Miden node RPC endpoint
+ .miden_endpoint(Endpoint::new("http://localhost:57291"))
+ // the GUARDIAN server endpoint
+ .guardian_endpoint("http://localhost:50051")
+ // the directory where the miden-client will store the account data
+ .account_dir("/tmp/multisig-client")
+ // generate a new Falcon keypair for GUARDIAN authentication
+ .generate_key()
+ .build()
+ .await?;
+
+ // 2. Create 1-of-3 multisig account
+ let signer_commitments = vec![
+ client.user_commitment(),
+ cosigner1_commitment,
+ cosigner2_commitment,
+ ];
+ let account = client.create_account(1, signer_commitments).await?;
+ client.push_account().await?;
+
+ println!("Account created: {}", account.id());
+
+ // 3. Create a transfer proposal
+ let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);
+ let proposal = client.propose_transaction(tx).await?;
+
+ println!("Proposal created: {}", proposal.id);
+
+ // 4. Cosigners sign (only one cosigner is needed)
+ client.sign_proposal(&proposal.id).await?;
+
+ // 5. Execute when threshold is met
+ client.execute_proposal(&proposal.id).await?;
+
+ println!("Transfer executed!");
+ Ok(())
+}
+```
+
+---
+
+## Core Concepts
+
+### Multisig Accounts
+
+A multisig account requires **M-of-N** signatures to authorize transactions:
+- **Threshold (M)**: Minimum signatures required
+- **Signers (N)**: Total number of authorized cosigners
+- **Commitment**: Each signer's Falcon public key commitment (32 bytes, 64 hex chars)
+
+### Guardian
+
+GUARDIAN is a coordination server that:
+- Stores the account state off-chain
+- Coordinates proposal signing between cosigners
+- Provides acknowledgment signatures for on-chain execution (ensures the new state is available for the rest of the cosigners)
+- Keeps multisig metadata private
+
+> **Note**: GUARDIAN server setup is covered in separate documentation. This SDK assumes a running GUARDIAN instance.
+
+### Proposal Lifecycle
+
+```
+┌──────────┐ ┌──────────┐ ┌───────────┐
+│ PENDING │ ──► │ READY │ ──► │ FINALIZED │
+└──────────┘ └──────────┘ └───────────┘
+ │ │ │
+ Collecting Threshold Executed
+ signatures met on-chain
+```
+
+**States:**
+- **Pending**: Proposal created, collecting signatures (shows X/Y signed)
+- **Ready**: Threshold met, can be executed
+- **Finalized**: Executed on-chain or discarded
+
+#### Chain-anchored execution
+
+Since Miden protocol 0.16 a signed transaction summary binds the reference
+block commitment, so a summary produced at one block cannot be reproduced by
+re-executing at a later one. Proposals therefore carry a **chain anchor**
+(`chain_anchor` in the proposal metadata): a serialized Miden `ChainAnchor`
+capturing the reference block the proposer executed at. Cosigners verify and
+the executor executes against that anchor, so everyone reproduces the exact
+summary the signatures authorize regardless of their own sync height. The
+anchor is validated on receipt — its internal consistency at deserialization,
+and its block commitment against the one signed into the summary — before
+anything executes against it. A proposal without an anchor cannot be verified
+or executed.
+
+### Custom Proposal Types
+
+Guardian accepts any non-empty `proposal_type`, not just the first-party
+operations (issue #266). A proposal whose type the SDK does not model is
+exposed as the **custom** bucket — `TransactionType::Custom` in Rust,
+`proposalType: 'custom'` in TypeScript — while the label is preserved
+(Rust `ProposalMetadata.proposal_type`, TypeScript `CustomProposalMetadata.rawProposalType`)
+so it can be displayed. The SDK normalizes the label to lowercase `snake_case`
+(trim + lowercase, then require `[a-z0-9_]+` — the same shape as built-in
+labels), so `b2agg` is accepted, `B2Agg` is lowercased to `b2agg`, and
+`add signer` / `add-signer` are rejected. (Normalization is SDK-side; the server
+itself still accepts any non-empty string.)
+
+Custom proposals can be listed, displayed, signed, and exported/imported.
+
+**Producer API (issue #266).** The integration that owns a custom type builds
+its own transaction and drives the create + execute ends; the SDK never
+executes a transaction it does not understand. The model is **symmetric across
+Rust and TypeScript**:
+
+- **Create** — `propose_custom_transaction(transaction_request_bytes, proposal_type)` (Rust) /
+ `createCustomProposal(transactionRequestBytes, proposalType)` (TS). The bytes are a
+ serialized transaction request; the SDK derives the summary and pushes the
+ proposal with the custom label. They are **not** stored on the server.
+ Cosigners then review and sign through the normal flow.
+
+ On a chain whose `verification_base_fee` is non-zero, the request must declare
+ its fee conversion salt. Call `fee_conversion_salt(salt)` in Rust or
+ `withFeeConversionSalt(salt)` in TypeScript. The Miden client derives the native
+ 1:1 conversion info from the execution reference header and commits it to the
+ auth argument. The typed proposal builders do this for you. A custom producer
+ must retain the original salt and use it again when rebuilding the request.
+ Do not pass `summaryAuthArg(summary)` to `withFeeConversionSalt`: the summary
+ contains the derived commitment, not the original salt.
+- **Execute** — `prepare_custom_execution(proposal_id, transaction_request_bytes)` (Rust) /
+ `prepareCustomExecution(proposalId, transactionRequestBytes)` (TS). The SDK verifies the
+ proposal is ready, binding-checks the request against the signed commitment
+ (before any acknowledgment request), fetches the GUARDIAN ack, and returns the
+ **advice** (cosigner signatures + ack). The integration injects that advice
+ into its own transaction and submits via its own client:
+
+ ```ts
+ // TypeScript: rebuild via the integration's builder (the wasm request is immutable)
+ const advice = await multisig.prepareCustomExecution(proposalId, transactionRequestBytes);
+ const finalReq = myBuilder.extendAdviceMap(advice).build();
+ await multisig.submitTransaction(proposalId, finalReq);
+ ```
+ ```rust
+ // Rust: inject into the request's advice map, submit via the SDK helper
+ let advice = client.prepare_custom_execution(&proposal_id, &transaction_request_bytes).await?;
+ let mut req = deserialize_transaction_request(&transaction_request_bytes)?;
+ req.advice_map_mut().extend(advice);
+ client.submit_transaction(&proposal_id, req).await?;
+ ```
+
+The SDK owns the security-critical pieces (binding check, signature + ack
+assembly, ack-after-binding ordering); the integration owns only the
+transaction recipe + submit. `execute_proposal` on a custom type returns a
+clear error pointing to `prepare_custom_execution`. Because the integration must
+rebuild its transaction to execute, **custom execution is performed by a party
+that holds the recipe** (typically the producer), not by an arbitrary cosigner.
+
+The returned advice is keyed by the signer and GUARDIAN commitments
+(domain-separated digests over the signed `tx_summary`), the same keys the
+SDK's own built-in execution uses. Extending a transaction's advice map with it
+therefore does not collide with the transaction's ordinary inputs; the
+integration extends rather than replaces its advice map.
+
+> **Security:** for first-party types the SDK reconstructs the transaction from
+> metadata and checks it against the signed `tx_summary` commitment. For custom
+> types there is no such reconstruction, so the SDK cannot verify that display
+> metadata (e.g. `description`) matches what the transaction actually does.
+> Cosigners must verify the raw `tx_summary` they are signing — not trust the
+> label or description.
+
+### Offline Workflow
+
+For air-gapped or offline signing scenarios:
+
+```
+┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+│ Proposer │ │ Cosigner │ │ Executor │
+│ (Online) │ │ (Air-gapped)│ │ (Online) │
+└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
+ │ │ │
+ │ Export proposal.json │ │
+ │──────────────────────►│ │
+ │ │ │
+ │ │ Sign offline │
+ │ │ │
+ │ │ Export signed.json │
+ │ │──────────────────────►│
+ │ │ │
+ │ │ Import & Execute
+ ▼ ▼ ▼
+```
+
+---
+
+## TypeScript SDK Guide
+
+### Installation & Setup
+
+```typescript
+import { MidenClient, AuthSecretKey } from '@miden-sdk/miden-sdk';
+import {
+ MultisigClient,
+ Multisig,
+ FalconSigner,
+ AccountInspector,
+ type MultisigConfig,
+} from '@openzeppelin/miden-multisig-client';
+
+// Initialize Miden client (connects to Miden node)
+const midenClient = await MidenClient.createDevnet();
+
+// Create signer from secret key
+const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined);
+const signer = new FalconSigner(secretKey);
+
+// Initialize multisig client
+const client = new MultisigClient(midenClient, {
+ guardianEndpoint: 'http://localhost:3000',
+ midenRpcEndpoint: 'https://rpc.devnet.miden.io',
+});
+```
+
+### Creating Accounts
+
+```typescript
+// Get GUARDIAN server's public key commitment
+const guardianCommitment = await client.guardianClient.getPubkey();
+
+// Define multisig configuration
+const config: MultisigConfig = {
+ threshold: 2, // 2 signatures required
+ signerCommitments: [ // 3 authorized signers
+ signer.commitment, // Your commitment
+ '0x1234...abcd', // Cosigner 1
+ '0x5678...efgh', // Cosigner 2
+ ],
+ guardianCommitment, // GUARDIAN server commitment
+};
+
+// Create the account
+const multisig = await client.create(config, signer);
+
+// Register with GUARDIAN (stores initial state)
+await multisig.registerOnGuardian();
+
+console.log('Account ID:', multisig.accountId);
+console.log('Threshold:', multisig.threshold);
+console.log('Signers:', multisig.signerCommitments);
+```
+
+### Loading Existing Accounts
+
+```typescript
+// Load as a cosigner joining an existing multisig
+const multisig = await client.load(accountId, signer);
+
+// Fetch latest state from GUARDIAN
+const state = await multisig.fetchState();
+
+// Inspect account configuration
+const detected = AccountInspector.fromBase64(state.stateDataBase64);
+console.log('Threshold:', detected.threshold);
+console.log('Signers:', detected.signerCommitments);
+console.log('Vault balances:', detected.vaultBalances);
+```
+
+### Delta History
+
+Guardian retains the account's canonical delta history, allowing a wallet to
+render its history after recovery. `deltaHistory()` returns one
+page at a time, newest-first by nonce, with server-decoded input and output
+note summaries: note ID, P2ID/P2IDE/swap/mint/burn classification, note
+visibility (`noteType`), assets, and sender or recipient when exposed by the
+note script. Every entry carries `status: 'canonical'` today; the set widens
+if the feed gains a status filter.
+
+```typescript
+let cursor: string | undefined;
+do {
+ const page = await multisig.deltaHistory({ limit: 50, cursor });
+ for (const entry of page.entries) {
+ console.log(`nonce ${entry.nonce} at ${entry.timestamp}`);
+ for (const note of entry.outputNotes) {
+ console.log(` sent ${note.tag} note ${note.noteId}`);
+ }
+ if (entry.decodeWarnings.length > 0) {
+ // Payload predates the current summary format; sections are empty.
+ }
+ }
+ cursor = page.nextCursor;
+} while (cursor !== undefined);
+```
+
+`limit` accepts 1–500 (default 50). Only canonical (confirmed)
+transactions appear — pending proposals live on `syncProposals()`. The
+feed is served even while the account is paused. Guardian only ever
+sees transactions pushed through it, so history of transactions the
+account executed elsewhere is not included. Output notes whose full
+details are not in the stored summary (e.g. private notes carried as
+partial notes) appear with `tag: 'custom'` and no recipient.
+
+### Recovering Notes After Device Loss
+
+Normal forward sync cannot see notes that landed behind the store's
+cursors. After `load()` on a recovered account, `multisig.recoverNotes()`
+runs the three recovery strategies as one flow — the private-note transport
+drain, the proposal-embedded note import, and the historical public-note
+backfill — and finishes with a normal sync (transport fetch, chain sync,
+and GUARDIAN state sync) so imported notes are verified and ready to
+consume. The flow is the one public entry point: the strategies are
+internal, so a caller cannot accidentally skip the context they need (a
+tracked account, a synced store) or the final verifying sync.
+
+```typescript
+const multisig = await client.load(accountId, signer);
+
+const report = await multisig.recoverNotes();
+console.log(`recovered ${report.imported} notes`);
+for (const problem of report.problems) {
+ // A strategy that could not run at all lands here; the flow continues
+ // with the remaining strategies either way.
+ console.warn(`step ${problem.step} did not run: ${problem.reason}`);
+}
+if (report.retryable) {
+ // The flow is idempotent — rerun it to plausibly recover more.
+}
+```
+
+Strategies are individually selectable and the backfill's block range can be
+bounded:
+
+```typescript
+// Rescan only the transport backlog and recent public history, skipping the
+// proposal import and the final verifying sync.
+const report = await multisig.recoverNotes({
+ proposalImport: false,
+ fromBlock: 1_690_000,
+ syncAfter: false,
+});
+```
+
+The combined `NoteRecoveryReport` carries each strategy's own report
+(`transport`, `proposalImport`, `backfill`) untouched, plus the `problems`
+list, `synced`, the total `imported` count, and an aggregate `retryable`
+flag.
+
+#### The transport-drain strategy
+
+A fresh store has no note-transport cursor — and in a store shared with
+other accounts, sync may have advanced the cursor past private notes
+addressed to the recovered account. The drain rescans the full transport
+backlog for every tracked note tag (running as many transport syncs as the
+upstream per-sync tag backfill cap requires), regardless of the stored
+cursor, and never regresses it. It is idempotent, and a failed drain
+restores the covered-tags bookkeeping so normal sync keeps working exactly
+as before the attempt.
+
+Transport problems land in `report.transport`, never as exceptions:
+`unavailable` means no transport endpoint is configured or the transport
+could not be reached before anything was imported; `failed` with
+`retryable: true` keeps any partial progress and rerunning continues it.
+
+**Not a backup:** transport recovery is bounded by the transport service's
+retention. Senders may deliver private notes out-of-band without using the
+transport, and relayed blobs are pruned after the retention window. Notes
+outside both are recoverable only from their sender (see the note
+export/import helpers).
+
+#### The proposal-import strategy
+
+v2 `consume_notes` proposals embed the serialized notes they consume, so
+pending proposals are opportunistic recovery material: the strategy syncs
+them from GUARDIAN (isolating per-proposal parse or binding failures as
+`invalid` outcomes instead of failing the whole step), validates each
+embedded note against the proposal's declared note ids, fetches on-chain
+inclusion proofs, and imports per note — so it works for private notes too;
+the node never needs to hold the note body.
+
+Each unique embedded note gets a `NoteImportOutcome` in
+`report.proposalImport` (`imported`, `already-present`, `already-consumed`,
+`not-committed`, `invalid`, or `failed`); duplicates across proposals fold
+into one outcome and no per-note problem blocks the others. A note not yet
+on chain is recorded as expected with its sync-hint tag so a later sync
+picks it up, and an import whose inclusion proof fails verification against
+the authenticated chain is demoted to `failed` rather than counted as
+recovered.
+
+Proposals are opportunistic recovery material, not a backup: v1 proposals
+carry no note bytes, proposals disappear once canonicalized, and embedded
+note bytes are visible to the GUARDIAN operator (existing v2 behavior, not
+a new exposure).
+
+#### The public-backfill strategy
+
+Normal forward sync starts from the store's **global** cursor, so in a
+shared store the cursor may already be past blocks containing the recovered
+account's notes. The backfill scans a historical block range — genesis to
+the current chain tip by default — for public notes addressed at the
+account's standard note tag and imports them with their on-chain inclusion
+proofs, without ever touching the global sync height. The scan's cost grows
+with the number of matching notes, not the range length, so a full
+genesis-to-tip scan is fast on an ordinary account. The flow syncs the
+chain state before this strategy runs, so it works on a store that has
+never synced.
+
+`report.backfill` carries the requested range (`scannedFrom` /
+`scannedTo`), the number of unique tag matches (`discovered`), the private
+matches skipped (`skippedPrivate` — the chain holds no body for them; the
+other two strategies cover those), the matches the relevance screen
+rejected (`skippedIrrelevant`), the matches this SDK's static screen could
+not judge (`skippedUnscreenable` — custom note scripts; the Rust SDK's
+execution-based screener judges every note and always reports `0` there),
+and one `NoteImportOutcome` per screened-in public note. A proof-less
+expected record left by an earlier proposal import is upgraded in place
+with the freshly fetched proof rather than skipped. Sub-ranges the scan
+could not cover land in `uncovered` with `retryable`/`reason` set, so a
+partial scan never aborts the rest of the flow.
+
+### Preserving Notes Across a Guardian Switch
+
+Pending proposals do not survive a guardian switch: the new GUARDIAN is
+registered with bare account state, so once the client repoints, the notes
+embedded in the old GUARDIAN's pending consume-notes proposals are the one
+recovery source `recoverNotes` can no longer reach. `executeProposal` runs
+the proposal-import slice of the flow automatically on the switch-guardian
+path — against the old GUARDIAN, before the switch transaction executes —
+so those notes land in the local store while they are still reachable
+(issue #417). The step is best-effort and bounded by a 30s timeout with
+cooperative cancellation: an unreachable or hung old GUARDIAN is warned
+about, never allowed to block the switch.
+
+The transport drain and public backfill deliberately do not run on the
+switch path: a switch executes against an intact local store, and both the
+note transport and the node are configured independently of the GUARDIAN,
+so a switch loses nothing they rescan. They remain device-loss recovery
+tools and work unchanged against the new GUARDIAN.
+
+A wallet repointing a follower client by hand should request the same
+preservation before switching clients:
+
+```typescript
+// Before setGuardianClient(newGuardian):
+const report = await multisig.preservePreSwitchProposalNotes();
+```
+
+`preservePreSwitchProposalNotes` is the one public entry point for switch
+preservation — it wraps the import slice in the switch-specific safety
+contract, so there is no equivalent way to request it through
+`recoverNotes` options. It returns the slice's `NoteRecoveryReport` (or
+`undefined` when the flow could not run or timed out) and never throws.
+On timeout a cancellation token stops every step and inner loop at its
+next checkpoint (including between RPC retry attempts), and the switch
+waits a short bounded grace for the one uninterruptible in-flight
+operation to settle so it cannot overlap the switch transaction. Imported
+notes are verified by the next normal sync. Like every listing-based flow
+this covers proposals pending at the next nonce; one superseded at an
+earlier nonce (it lost a same-nonce race) is filtered out and its embedded
+notes are not imported here.
+
+### Proposal Operations
+
+Every `create*Proposal` method takes a single trailing options object (issue
+#387). All of them accept an optional `nonce` that identifies the proposal
+(defaults to `Date.now()`); method-specific options are listed with each
+method below. Passing a legacy positional `nonce` number where the options
+object is expected throws instead of silently applying defaults.
+
+#### P2ID Transfer (Send Funds)
+
+```typescript
+const proposal = await multisig.createP2idProposal(
+ recipientAccountId, // Recipient's account ID
+ faucetAccountId, // Faucet (token) ID
+ 1000n // Amount to send
+);
+
+// Private note: only the note's hash is published on chain. Pass `noteType`
+// in the options object (`NoteType` comes from `@miden-sdk/miden-sdk`).
+import { NoteType } from '@miden-sdk/miden-sdk';
+
+const privateProposal = await multisig.createP2idProposal(
+ recipientAccountId,
+ faucetAccountId,
+ 1000n,
+ { noteType: NoteType.Private }, // note visibility; defaults to NoteType.Public
+);
+
+// P2IDE note (issue #366): pass `reclaimHeight` and/or `timelockHeight`
+// (absolute block heights) to create a reclaimable/timelocked note instead
+// of a plain P2ID note. `reclaimHeight` lets the sender reclaim the note if
+// it is still unconsumed at that block; `timelockHeight` prevents
+// consumption before that block.
+const reclaimableProposal = await multisig.createP2idProposal(
+ recipientAccountId,
+ faucetAccountId,
+ 1000n,
+ { reclaimHeight: 500_000 },
+);
+```
+
+Note that the two heights are independent and the SDK does not cross-check
+them: a `timelockHeight` at or above `reclaimHeight` means the recipient
+never has a window in which they can claim before the sender can reclaim —
+usually not what you want.
+
+> **Warning:** a `private` P2ID note publishes only its hash on chain. The
+> recipient cannot discover the note by syncing; the full note details must
+> be shared with them out-of-band before they can consume it. Use
+> `exportNoteToBytes` / `importNoteFromBytes` (or the browser file variants
+> `exportNoteToFile` / `importNoteFromFile`) for that transfer (issue #356):
+
+```typescript
+// Sender: resolve the note ID BEFORE executing (it derives from the
+// pre-execution vault state), then export after execution.
+const noteId = await multisig.getP2idNoteId(privateProposal);
+// ...sign + execute the proposal...
+const noteFileBytes = await multisig.exportNoteToBytes(noteId);
+// Deliver `noteFileBytes` to the recipient out-of-band (file, message, ...).
+// Or, in a browser, trigger a download of the note file directly:
+await multisig.exportNoteToFile(noteId);
+
+// Recipient: import the bytes (or a File from an ), then
+// sync so the note's on-chain commitment is tracked; it then appears in
+// getConsumableNotes() and can be consumed with createConsumeNotesProposal
+// as usual.
+const importedNoteId = await multisig.importNoteFromBytes(noteFileBytes);
+```
+
+> **Note:** every cosigner device that verifies or signs the consume-notes
+> proposal needs the note in its local store with the on-chain inclusion
+> proof — deliver the note file to each of them (import + sync), not just to
+> the proposer. A cosigner whose store lacks the authenticated note rebuilds
+> the transaction differently (the input-notes commitment distinguishes
+> authenticated from unauthenticated consumption) and rejects the proposal
+> with `metadata does not match tx_summary`. The sender's own device heals
+> itself: it already knows the full note, so a post-commit sync is enough.
+
+#### Consume Notes (Claim Received Funds)
+
+```typescript
+// Get consumable notes
+const notes = await multisig.getConsumableNotes();
+
+// Create proposal to consume them
+const noteIds = notes.map(n => n.id);
+const proposal = await multisig.createConsumeNotesProposal(noteIds);
+```
+
+A private note received out-of-band must first be loaded with
+`importNoteFromBytes(noteFileBytes)` or `importNoteFromFile(file)` (see the
+P2ID section above); after a sync it shows up in `getConsumableNotes()` like
+any public note.
+
+#### Add Signer
+
+```typescript
+const proposal = await multisig.createAddSignerProposal(
+ newSignerCommitment, // New signer's public key commitment
+ { newThreshold }, // Options: nonce, newThreshold (defaults to current)
+);
+```
+
+> **Override dilution on signer growth**: per-procedure threshold overrides
+> are absolute signature counts, and the on-chain update never re-scales
+> them — growing the signer set silently lowers every override's effective
+> signing ratio (a 2-of-2 override becomes 2-of-n). Both SDKs surface this:
+> the TypeScript SDK logs a `console.warn` per affected override and exposes
+> `multisig.overridesDilutedBySignerGrowth(newNumSigners)`; the Rust SDK
+> emits a `tracing::warn!` in `propose_transaction` and exposes
+> `MultisigAccount::overrides_diluted_by_signer_growth(new_num_signers)`.
+> To keep the intended security level, raise the affected overrides via an
+> update-procedure-threshold proposal alongside the growth.
+
+> **Overrides apply to guardian rotation too**: `update_guardian` is a valid
+> override target in both SDKs. Guardian rotation is a note-less operation, so
+> the upstream contract skips the guardian signature check when
+> `update_guardian_public_key` is the only non-auth procedure called — the
+> multisig quorum alone authorizes it. That quorum is the override on
+> `update_guardian`'s root when one is set, so an override of 1 lets a single
+> signer replace the guardian with no guardian consent. Nothing in the builder
+> or the contract restricts overrides on this root; treat an override on
+> `update_guardian` as a deliberate reduction of the account's recovery
+> threshold. Installing such an override is gated — at creation the account's
+> author chooses it, and at runtime `set_procedure_threshold` requires the
+> default quorum plus a guardian signature — but the gate applies only to
+> installing it. Once stored, the reduced quorum governs every future rotation
+> on its own, with no further guardian involvement, until another
+> update-procedure-threshold proposal raises it back.
+
+#### Remove Signer
+
+```typescript
+const proposal = await multisig.createRemoveSignerProposal(
+ signerToRemove, // Signer's commitment to remove
+ { newThreshold }, // Options: nonce, newThreshold (defaults to
+ // min(current threshold, remaining signer count))
+);
+```
+
+#### Change Threshold
+
+```typescript
+const proposal = await multisig.createChangeThresholdProposal(
+ newThreshold // New threshold value
+);
+```
+
+#### Switch GUARDIAN Provider
+
+```typescript
+const proposal = await multisig.createSwitchGuardianProposal(
+ newGuardianEndpoint, // New GUARDIAN server URL
+ newGuardianCommitment // New GUARDIAN server's public key
+);
+```
+
+When the current GUARDIAN is unreachable, create the switch proposal fully
+offline instead — nothing is pushed to the current operator (issue #433;
+mirrors the Rust `create_proposal_offline`). The returned `ExportedProposal`
+already carries the proposer's signature; share its JSON with cosigners for
+`importProposal` / `signProposalOffline`, then execute:
+
+```typescript
+const exported = await multisig.createSwitchGuardianProposalOffline(
+ newGuardianEndpoint,
+ newGuardianCommitment
+);
+```
+
+### Signing & Executing Proposals
+
+```typescript
+// List all pending proposals
+const proposals = await multisig.syncProposals();
+
+for (const proposal of proposals) {
+ console.log(`${proposal.id}: ${proposal.status.type}`);
+
+ if (proposal.status.type === 'pending') {
+ console.log(` Signatures: ${proposal.status.signaturesCollected}/${proposal.status.signaturesRequired}`);
+ }
+}
+
+// Sign a proposal
+const signed = await multisig.signProposal(proposalId);
+
+// Execute when ready
+if (signed.status.type === 'ready') {
+ await multisig.executeProposal(proposalId);
+}
+```
+
+### Offline Export/Import
+
+```typescript
+// Export proposal for offline signing
+const json = multisig.exportProposalToJson(proposalId);
+// Share via file, QR code, etc.
+
+// On air-gapped machine: import and sign
+const imported = multisig.importProposal(json);
+const signedJson = multisig.signProposalOffline(proposalId);
+
+// Back on online machine: import signed proposal
+const signedProposal = multisig.importProposal(signedJson);
+await multisig.executeProposal(signedProposal.id);
+```
+
+### Recovering Accounts By Key
+
+Use `recoverByKey` when a wallet has a signing key from an account's
+authorization set but does not know the account ID yet. The helper queries
+Guardian's `/state/lookup` endpoint with proof-of-possession of the key,
+fetches state for each matching account, and returns `(accountId, state)`
+pairs.
+
+```typescript
+const recovered = await client.recoverByKey(signer);
+
+if (recovered.length === 0) {
+ console.log('No account on this Guardian authorizes this key');
+}
+
+for (const { accountId, state } of recovered) {
+ console.log('Recovered account:', accountId);
+ console.log('State commitment:', state.commitment);
+
+ const multisig = await client.load(accountId, signer);
+ // Continue with normal proposal or sync flows.
+}
+```
+
+The signer passed to `recoverByKey` must implement `signLookupMessage`. The
+bundled `FalconSigner`, `EcdsaSigner`, Miden Wallet signer, and Para signer
+support it. Multiple matches are valid: the same key commitment may authorize
+more than one account, and the method returns all matches instead of choosing
+one implicitly.
+
+### API Reference
+
+#### MultisigClient
+
+| Method | Description |
+|--------|-------------|
+| `create(config, signer)` | Create new multisig account |
+| `load(accountId, signer)` | Load existing account from GUARDIAN |
+| `recoverByKey(signer)` | Discover accounts that authorize the signer's key and fetch each current state |
+| `guardianClient` | Access to underlying GUARDIAN HTTP client |
+
+#### Multisig
+
+| Method | Description |
+|--------|-------------|
+| `accountId` | Get account ID (hex string) |
+| `threshold` | Get current threshold |
+| `signerCommitments` | Get list of signer commitments |
+| `fetchState()` | Fetch latest state from GUARDIAN |
+| `registerOnGuardian()` | Register new account with GUARDIAN |
+| `syncProposals()` | Sync proposals from GUARDIAN |
+| `abandonCandidate(nonce)` | Record an abandon intent for a stuck candidate (worker resolves after a short quarantine) |
+| `abandonStatus(nonce)` | Poll the abandon resolution: `waiting` / `landed` / `abandoned` / `unexpected` |
+| `deltaHistory({ limit?, cursor? }?)` | One page of canonical delta history, newest-first, with decoded note summaries |
+| `listProposals()` | Get cached proposals |
+| `createP2idProposal(recipient, faucet, amount, { nonce, noteType, reclaimHeight, timelockHeight }?)` | Create transfer proposal (`noteType`: `NoteType.Public` (default) or `NoteType.Private`; presence of `reclaimHeight`/`timelockHeight` creates a P2IDE note, issue #366) |
+| `createConsumeNotesProposal(noteIds, { nonce }?)` | Create note consumption proposal |
+| `getP2idNoteId(proposal)` | Compute the note ID a P2ID proposal creates (call before executing) |
+| `exportNoteToBytes(noteId)` | Export a created note as note-file bytes for out-of-band delivery |
+| `exportNoteToFile(noteId, filename?)` | Browser-only: download the note file |
+| `importNoteFromBytes(noteBytes)` | Import a note file received out-of-band |
+| `importNoteFromFile(file)` | Import a note file from a browser `File`/`Blob` |
+| `recoverNotes(options?)` | Run the note-recovery strategies (transport drain, proposal import, public backfill) as one flow with a final verifying sync; returns a combined `NoteRecoveryReport` |
+| `preservePreSwitchProposalNotes()` | Pre-switch slice of the flow (issue #417): import notes embedded in the old GUARDIAN's pending proposals before repointing; run automatically by `executeProposal` on the switch path; returns the report or `undefined` |
+| `createAddSignerProposal(commitment, { nonce, newThreshold }?)` | Create add signer proposal (`newThreshold` defaults to the current threshold) |
+| `createRemoveSignerProposal(commitment, { nonce, newThreshold }?)` | Create remove signer proposal (`newThreshold` defaults to min of current threshold and remaining signer count) |
+| `createChangeThresholdProposal(threshold, { nonce }?)` | Create threshold change proposal |
+| `createUpdateProcedureThresholdProposal(procedure, threshold, { nonce }?)` | Create per-procedure threshold override proposal (`threshold: 0` clears the override) |
+| `createSwitchGuardianProposal(endpoint, pubkey, { nonce }?)` | Create GUARDIAN switch proposal |
+| `createSwitchGuardianProposalOffline(endpoint, pubkey, { nonce }?)` | Create GUARDIAN switch proposal without contacting the current GUARDIAN; returns a signed `ExportedProposal` for side-channel cosigning (issue #433) |
+| `createCustomProposal(requestBytes, label, { nonce }?)` | Create a producer-built custom proposal (issue #266) |
+| `signProposal(id)` | Sign a proposal |
+| `executeProposal(id)` | Execute ready proposal |
+| `exportProposalToJson(id)` | Export for offline signing |
+| `importProposal(json)` | Import offline proposal |
+| `signProposalOffline(id)` | Sign imported proposal offline |
+| `getConsumableNotes()` | Get notes that can be consumed |
+| `getSignerPublicKeyCommitments()` | Read the current signer public-key commitments from account storage, ordered by signer index (strict; throws on partial reads) |
+| `getGuardianPublicKeyCommitment()` | Read the current guardian commitment from account storage (strict; throws when the entry is missing — the guarded-multisig always includes a guardian) |
+
+#### FalconSigner
+
+| Property/Method | Description |
+|-----------------|-------------|
+| `commitment` | Public key commitment (hex) |
+| `publicKey` | Serialized public key (hex) |
+| `signRequest(id, timestamp, requestPayload)` | Sign account ID + timestamp + request payload digest for auth |
+| `signCommitment(hex)` | Sign commitment/word |
+| `signLookupMessage(timestamp, keyCommitment)` | Sign account-less lookup digest for `recoverByKey` |
+
+#### AccountInspector
+
+| Method | Description |
+|--------|-------------|
+| `fromBase64(data)` | Inspect base64-encoded account |
+| `fromAccount(account)` | Inspect Account object |
+| `getSignerPublicKeyCommitments(account)` | Read the signer public-key commitments ordered by signer index (strict; throws on a foreign contract version or any absent entry) |
+| `getGuardianPublicKeyCommitment(account)` | Read the guardian commitment (strict; throws on a foreign contract version or a missing entry) |
+
+`fromBase64` / `fromAccount` return `DetectedMultisigConfig`:
+- `threshold`: number
+- `numSigners`: number
+- `signerCommitments`: string[]
+- `guardianCommitment`: string | null
+- `vaultBalances`: \{ faucetId, amount \}[]
+
+> **Reading an account's keys:** since the account uses the upstream
+> `AuthGuardedMultisig` component, the Miden SDK's
+> `Account.getPublicKeyCommitments()` returns the approver commitments
+> natively. The accessors above are the strict, layout-insulated
+> alternative (issue #306): they validate the complete set against the
+> configured signer count and throw instead of silently omitting
+> unreadable entries, and they shield consumers from storage-layout
+> changes across contract versions (both are gated on the pinned contract
+> version — see [Contract version pinning](#contract-version-pinning)).
+> Commitments are ordered by signer index as currently stored (indices
+> re-pack when signers are removed); hot/cold roles are a consumer-side
+> convention. The `Account` must come from the same copy of
+> `@miden-sdk/miden-sdk` that this package links — a separately bundled
+> SDK copy is rejected by the SDK's instance checks.
+
+---
+
+## Rust SDK Guide
+
+### Installation & Setup
+
+```rust
+use miden_multisig_client::{
+ MultisigClient, MultisigClientBuilder,
+ MultisigAccount, TransactionType,
+ Proposal, ProposalStatus,
+ KeyManager, GuardianKeyStore,
+ Endpoint, Word, AccountId, SecretKey,
+};
+
+// Build client with fluent API
+let mut client = MultisigClient::builder()
+ .miden_endpoint(Endpoint::new("http://localhost:57291"))
+ .guardian_endpoint("http://localhost:50051")
+ .account_dir("/tmp/multisig-data")
+ .generate_key() // Or: .with_secret_key(key)
+ .build()
+ .await?;
+```
+
+### Creating Accounts
+
+```rust
+// Collect signer commitments (your key + cosigners)
+let signer_commitments = vec![
+ client.user_commitment(), // Your commitment
+ commitment_from_hex("0x1234...")?, // Cosigner 1
+ commitment_from_hex("0x5678...")?, // Cosigner 2
+];
+
+// Create 2-of-3 multisig
+let account = client.create_account(2, signer_commitments).await?;
+
+// Register with GUARDIAN
+client.push_account().await?;
+
+println!("Created account: {}", account.id());
+println!("Threshold: {}", account.threshold()?);
+println!("Signers: {:?}", account.cosigner_commitments_hex());
+```
+
+### Loading Existing Accounts
+
+```rust
+// Pull account from GUARDIAN (as a cosigner)
+let account = client.pull_account(account_id).await?;
+
+// Sync with Miden network
+client.sync().await?;
+
+// Inspect account
+println!("Threshold: {}", account.threshold()?);
+println!("Nonce: {}", account.nonce());
+println!("GUARDIAN commitment: {:?}", account.guardian_commitment()?);
+```
+
+### Delta History
+
+Guardian retains the account's canonical delta history, allowing a wallet to
+render its history after recovery. `delta_history()` returns one
+`HistoryPage` at a time, newest-first by nonce, with server-decoded input and
+output note summaries (typed tags, visibility, assets, counterparties). Every
+entry carries `HistoryEntryStatus::Canonical` today; the set widens if the
+feed gains a status filter.
+
+```rust
+let mut cursor: Option = None;
+loop {
+ let page = client.delta_history(Some(50), cursor.take()).await?;
+ for entry in &page.entries {
+ println!("nonce {} at {}", entry.nonce, entry.timestamp);
+ for note in &entry.output_notes {
+ println!(" sent {} note {}", note.tag.as_str(), note.note_id);
+ }
+ }
+ match page.next_cursor {
+ Some(next) => cursor = Some(next),
+ None => break,
+ }
+}
+```
+
+`limit` accepts 1–500 (server default 50 when `None`). Only canonical
+(confirmed) transactions appear — pending proposals live on
+`list_proposals()`. The feed is served even while the account is
+paused. Guardian only ever sees transactions pushed through it, so
+history of transactions the account executed elsewhere is not included.
+Output notes whose full details are not in the stored summary (e.g.
+private notes carried as partial notes) appear with tag `custom` and no
+recipient.
+
+### Recovering Notes After Device Loss
+
+Normal forward sync cannot see notes that landed behind the store's
+cursors. After `pull_account` on a recovered account, `recover_notes` runs
+the three recovery strategies as one flow — the private-note transport
+drain, the proposal-embedded note import, and the historical public-note
+backfill — and finishes with a normal sync so imported notes are verified
+and ready to consume. The flow is the one public entry point: the
+strategies are internal, so a caller cannot accidentally skip the context
+they need (a tracked account, a synced store) or the final verifying sync.
+
+```rust
+client.pull_account(account_id).await?;
+
+// `None` runs every strategy over the full chain and syncs afterwards.
+let report = client.recover_notes(None).await?;
+println!("recovered {} notes", report.imported);
+for problem in &report.problems {
+ // A strategy that could not run at all lands here; the flow continues
+ // with the remaining strategies either way.
+ eprintln!("step {} did not run: {}", problem.step, problem.reason);
+}
+if report.retryable {
+ // The flow is idempotent — rerun it to plausibly recover more.
+}
+```
+
+Strategies are individually selectable and the backfill's block range can be
+bounded:
+
+```rust
+use miden_multisig_client::{NoteRecoveryOptions, PublicBackfillOptions};
+
+// Rescan only the transport backlog and recent public history, skipping the
+// proposal import and the final verifying sync.
+let report = client
+ .recover_notes(Some(NoteRecoveryOptions {
+ proposal_import: false,
+ backfill: PublicBackfillOptions {
+ from_block: Some(1_690_000u32.into()),
+ ..Default::default()
+ },
+ sync_after: false,
+ ..Default::default()
+ }))
+ .await?;
+```
+
+The combined `NoteRecoveryReport` carries each strategy's own report
+(`transport`, `proposal_import`, `backfill`) untouched, plus the `problems`
+list, `synced`, the total `imported` count, and an aggregate `retryable`
+flag.
+
+#### The transport-drain strategy
+
+A fresh store has no note-transport cursor — and in a store shared with
+other accounts, sync may have advanced the cursor past private notes
+addressed to the recovered account. The drain rescans the full transport
+backlog for every tracked note tag (running as many transport syncs as the
+upstream per-sync tag backfill cap requires), regardless of the stored
+cursor, and never regresses it. It is idempotent, and a failed drain
+restores the covered-tags bookkeeping so normal sync keeps working exactly
+as before the attempt.
+
+Transport problems land in `report.transport`, never as errors:
+`Unavailable` means no transport endpoint is configured or the transport
+could not be reached before anything was imported; `Failed` with
+`retryable: true` keeps any partial progress and rerunning continues it.
+
+**Not a backup:** transport recovery is bounded by the transport service's
+retention. Senders may deliver private notes out-of-band without using the
+transport, and relayed blobs are pruned after the retention window. Notes
+outside both are recoverable only from their sender (see
+`import_note_from_file`).
+
+#### The proposal-import strategy
+
+v2 `consume_notes` proposals embed the serialized notes they consume, so
+pending proposals are opportunistic recovery material: the strategy lists
+them from GUARDIAN (isolating per-proposal parse or binding failures as
+`Invalid` outcomes instead of failing the whole step), validates each
+embedded note against the proposal's declared note ids, fetches on-chain
+inclusion proofs, and imports per note — so it works for private notes too;
+the node never needs to hold the note body.
+
+Each unique embedded note gets a `NoteImportOutcome` in
+`report.proposal_import` (`Imported`, `AlreadyPresent`, `AlreadyConsumed`,
+`NotCommitted`, `Invalid`, or `Failed`); duplicates across proposals fold
+into one outcome and no per-note problem blocks the others. A note not yet
+on chain is recorded in `Expected` state with its sync-hint tag so a later
+sync picks it up, and an import whose inclusion proof fails verification
+against the authenticated chain is demoted to `Failed` rather than counted
+as recovered.
+
+Proposals are opportunistic recovery material, not a backup: v1 proposals
+carry no note bytes, proposals disappear once canonicalized, and the
+embedded bytes are visible to the GUARDIAN operator (existing v2 behavior,
+not a new exposure).
+
+#### The public-backfill strategy
+
+Normal forward sync starts from the store's **global** cursor, so in a
+shared store the cursor may already be past blocks containing the recovered
+account's notes. The backfill scans a historical block range — genesis to
+the current chain tip by default — for public notes addressed at the
+account's standard note tag and imports them with their on-chain inclusion
+proofs, without ever touching the global sync height. The scan's cost grows
+with the number of matching notes, not the range length, so a full
+genesis-to-tip scan is fast on an ordinary account. The flow syncs the
+chain state before this strategy runs, so it works on a store that has
+never synced.
+
+`report.backfill` carries the requested range (`scanned_from` /
+`scanned_to`), the number of unique tag matches (`discovered`), the private
+matches skipped (`skipped_private` — the chain holds no body for them; the
+other two strategies cover those), the matches the execution-based
+`NoteScreener` rejected (`skipped_irrelevant` — tags are best-effort,
+truncated filters, and exactly like normal sync only notes the account can
+actually consume are imported), and one `NoteImportOutcome` per screened-in
+public note (`skipped_unscreenable` exists for cross-SDK parity and is
+always `0` here). A proof-less expected record left by an earlier proposal
+import is upgraded in place with the freshly fetched proof rather than
+skipped. Sub-ranges the scan could not cover land in `uncovered` with
+`retryable`/`reason` set, so a partial scan never aborts the rest of the
+flow.
+
+### Preserving Notes Across a Guardian Switch
+
+Pending proposals do not survive a guardian switch: the new GUARDIAN is
+registered with bare account state, so once the client repoints, the notes
+embedded in the old GUARDIAN's pending consume-notes proposals are the one
+recovery source `recover_notes` can no longer reach. `execute_proposal`
+runs the proposal-import slice of the flow automatically on the
+switch-guardian path — against the old GUARDIAN, before the switch
+transaction executes — so those notes land in the local store while they
+are still reachable (issue #417). The step is best-effort and bounded by a
+30s timeout: an unreachable or hung old GUARDIAN is warned about, never
+allowed to block the switch.
+
+The transport drain and public backfill deliberately do not run on the
+switch path: a switch executes against an intact local store, and both the
+note transport and the node are configured independently of the GUARDIAN,
+so a switch loses nothing they rescan. They remain device-loss recovery
+tools and work unchanged against the new GUARDIAN. The offline switch flow
+(`execute_imported_proposal`) also skips the import — it exists to avoid
+contacting the GUARDIAN at all — so when the old GUARDIAN is in fact still
+reachable, run the preservation explicitly before executing offline.
+
+A wallet repointing a follower client by hand should request the same
+preservation before `set_guardian_endpoint`:
+
+```rust
+// Before set_guardian_endpoint(new_endpoint, true):
+let report = client.preserve_pre_switch_proposal_notes().await;
+```
+
+`preserve_pre_switch_proposal_notes` is the one public entry point for
+switch preservation — it wraps the import slice in the switch-specific
+safety contract (timeout, cancellation, warnings), so there is no
+equivalent way to request it through `recover_notes` options. It returns
+the slice's `NoteRecoveryReport` (`None` when the flow could not run or
+timed out) and never errors; on timeout the in-flight flow is cancelled
+and the switch proceeds. Imported notes are verified by the next normal
+sync. Like every listing-based flow this covers proposals pending at the
+next nonce; one superseded at an earlier nonce (it lost a same-nonce
+race) is filtered out and its embedded notes are not imported here.
+
+### Transaction Types
+
+```rust
+// P2ID Transfer (public note)
+let tx = TransactionType::transfer(recipient_id, faucet_id, 1000);
+
+// P2ID Transfer with a private note (only the note hash is published on
+// chain; the recipient needs the note shared out-of-band — see
+// "Out-of-Band Note Transfer" below). `NoteType` is re-exported from
+// `miden_protocol::note`.
+let tx = TransactionType::transfer_with_note_type(
+ recipient_id, faucet_id, 1000, NoteType::Private,
+);
+
+// P2IDE Transfer (issue #366): optional reclaim and/or timelock block
+// heights. Presence of either height creates a P2IDE note instead of a
+// plain P2ID note. `P2ideHeights` uses `NonZeroU32`, so the invalid zero
+// height is unrepresentable.
+use std::num::NonZeroU32;
+use miden_multisig_client::P2ideHeights;
+
+let tx = TransactionType::transfer_p2ide(
+ recipient_id, faucet_id, 1000, NoteType::Public,
+ P2ideHeights { reclaim: NonZeroU32::new(500_000), timelock: None },
+);
+
+// Consume Notes
+let tx = TransactionType::consume_notes(vec![note_id1, note_id2]);
+
+// Add Cosigner
+let tx = TransactionType::add_cosigner(new_commitment);
+
+// Remove Cosigner
+let tx = TransactionType::remove_cosigner(commitment_to_remove);
+
+// Update Signers (change threshold and/or signer set)
+let tx = TransactionType::update_signers(new_threshold, new_signer_list);
+
+// Switch GUARDIAN Provider
+let tx = TransactionType::switch_guardian(new_endpoint, new_commitment);
+```
+
+### Proposal Operations
+
+```rust
+// Create and submit proposal
+let proposal = client.propose_transaction(tx).await?;
+println!("Proposal ID: {}", proposal.id);
+
+// Or with offline fallback
+match client.propose_with_fallback(tx).await? {
+ ProposalResult::Online(proposal) => {
+ println!("Submitted to GUARDIAN: {}", proposal.id);
+ }
+ ProposalResult::Offline(exported) => {
+ // Save for file-based sharing
+ std::fs::write("proposal.json", exported.to_json()?)?;
+ }
+}
+```
+
+### Listing & Signing Proposals
+
+```rust
+// List pending proposals
+let proposals = client.list_proposals().await?;
+
+for proposal in &proposals {
+ match &proposal.status {
+ ProposalStatus::Pending => {
+ let (signatures_collected, signatures_required) = proposal.signature_counts();
+ println!("{}: {}/{} signatures",
+ proposal.id, signatures_collected, signatures_required);
+ println!(" Signed by: {:?}", proposal.metadata.signers);
+ }
+ ProposalStatus::Ready => {
+ println!("{}: Ready to execute", proposal.id);
+ }
+ ProposalStatus::Finalized => {
+ println!("{}: Already executed", proposal.id);
+ }
+ }
+}
+
+// Sign a proposal
+client.sign_proposal(&proposal_id).await?;
+
+// Execute when ready
+client.execute_proposal(&proposal_id).await?;
+```
+
+### Offline Export/Import
+
+```rust
+// Create offline proposal (when GUARDIAN unavailable)
+let exported = client.create_proposal_offline(tx).await?;
+std::fs::write("proposal.json", exported.to_json()?)?;
+
+// On air-gapped machine: load and sign
+let json = std::fs::read_to_string("proposal.json")?;
+let mut exported: ExportedProposal = serde_json::from_str(&json)?;
+client.sign_imported_proposal(&mut exported)?;
+std::fs::write("signed.json", exported.to_json()?)?;
+
+// Back online: execute
+let json = std::fs::read_to_string("signed.json")?;
+let exported: ExportedProposal = serde_json::from_str(&json)?;
+client.execute_imported_proposal(&exported).await?;
+```
+
+### Note Filtering
+
+```rust
+use miden_multisig_client::NoteFilter;
+
+// List all consumable notes
+let notes = client.list_consumable_notes().await?;
+
+// Filter by faucet
+let filter = NoteFilter::by_faucet(faucet_id);
+let notes = client.list_consumable_notes_filtered(filter).await?;
+
+// Filter by faucet with minimum amount
+let filter = NoteFilter::by_faucet_min_amount(faucet_id, 5000);
+let notes = client.list_consumable_notes_filtered(filter).await?;
+
+for note in notes {
+ println!("Note {}: {} tokens", note.id, note.amount_for_faucet(faucet_id));
+}
+```
+
+### Out-of-Band Note Transfer (Private Notes)
+
+A private P2ID note publishes only its commitment on chain, so the recipient's
+client can never learn the note contents via sync. The sender must export the
+note and deliver the file out-of-band (issue #356):
+
+```rust
+// Sender: resolve the note ID BEFORE executing (it derives from the
+// pre-execution vault state), then export after execution.
+let note_id = client.p2id_note_id(&proposal)?;
+client.execute_proposal(&proposal.id).await?;
+client.export_note_to_file(¬e_id.to_hex(), Path::new("note.mno")).await?;
+// Deliver note.mno to the recipient out-of-band (file, message, ...).
+
+// Recipient: import the file, then sync so the note's on-chain commitment
+// is tracked; it then appears in list_consumable_notes() and can be
+// consumed with a regular consume-notes proposal.
+let imported_note_id = client.import_note_from_file(Path::new("note.mno")).await?;
+client.sync().await?;
+```
+
+`export_note_to_bytes` / `import_note_from_bytes` are the in-memory variants
+for programmatic delivery.
+
+Every cosigner device that verifies or signs the consume-notes proposal needs
+the note in its local store with the on-chain inclusion proof — deliver the
+note file to each of them (import + sync), not just to the proposer. A
+cosigner whose store lacks the authenticated note rebuilds the transaction
+differently (the input-notes commitment distinguishes authenticated from
+unauthenticated consumption) and rejects the proposal with `metadata does not
+match tx_summary`. The sender's own device heals itself: it already knows the
+full note, so a post-commit sync is enough.
+
+### Recovering Accounts By Key
+
+Use `recover_by_key` when the configured signer is known but the account ID is
+not. The client signs a lookup-bound authentication message, asks Guardian for
+accounts that authorize the signer's commitment, fetches state for each match,
+and returns `RecoveredAccount` values.
+
+```rust
+let recovered = client.recover_by_key().await?;
+
+if recovered.is_empty() {
+ println!("No account on this Guardian authorizes this key");
+}
+
+for entry in recovered {
+ println!("Recovered account: {}", entry.account_id);
+ println!("State commitment: {}", entry.state.commitment);
+
+ let account_id = AccountId::from_hex(&entry.account_id)?;
+ client.pull_account(account_id).await?;
+ // Continue with normal proposal or sync flows.
+}
+```
+
+An empty list means the key is valid but this Guardian has no account metadata
+that authorizes its commitment. Authentication failures, malformed lookup
+responses, and per-account `get_state` failures are returned as errors.
+
+### API Reference
+
+#### MultisigClient
+
+| Method | Description |
+|--------|-------------|
+| `builder()` | Create builder for configuration |
+| `create_account(threshold, commitments)` | Create new multisig |
+| `pull_account(id)` | Join existing multisig |
+| `push_account()` | Register account with GUARDIAN |
+| `sync()` | Sync with Miden network |
+| `account()` | Get loaded account (Option) |
+| `account_id()` | Get account ID (Option) |
+| `user_commitment()` | Get user's key commitment |
+| `user_commitment_hex()` | Get commitment as hex |
+| `recover_by_key()` | Discover accounts that authorize the configured signer and fetch each current state |
+| `propose_transaction(tx)` | Create and submit proposal |
+| `propose_with_fallback(tx)` | Online or offline proposal |
+| `list_proposals()` | List pending proposals |
+| `sign_proposal(id)` | Sign a proposal |
+| `execute_proposal(id)` | Execute ready proposal |
+| `abandon_candidate(nonce)` | Record an abandon intent for a stuck candidate (worker resolves after a short quarantine) |
+| `abandon_status(nonce)` | Poll the abandon resolution: `Waiting` / `Landed` / `Abandoned` / `Unexpected` |
+| `delta_history(limit, cursor)` | One page of canonical delta history, newest-first, with decoded note summaries |
+| `create_proposal_offline(tx)` | Create offline proposal |
+| `sign_imported_proposal(exported)` | Sign offline proposal |
+| `execute_imported_proposal(exported)` | Execute offline proposal |
+| `export_proposal(id, path)` | Export to file |
+| `import_proposal(path)` | Import from file |
+| `list_consumable_notes()` | List available notes |
+| `list_consumable_notes_filtered(filter)` | Filter notes |
+| `p2id_note_id(proposal)` | Compute the note ID a P2ID proposal creates (call before executing) |
+| `export_note_to_file(note_id, path)` | Export a created note to a file for out-of-band delivery |
+| `export_note_to_bytes(note_id)` | Export a created note as note-file bytes |
+| `import_note_from_file(path)` | Import a note file received out-of-band |
+| `import_note_from_bytes(bytes)` | Import a note from note-file bytes |
+| `recover_notes(options)` | Run the note-recovery strategies (transport drain, proposal import, public backfill) as one flow with a final verifying sync; returns a combined `NoteRecoveryReport` |
+| `preserve_pre_switch_proposal_notes()` | Pre-switch slice of the flow (issue #417): import notes embedded in the old GUARDIAN's pending proposals before repointing; run automatically by `execute_proposal` on the switch path; returns `Option` |
+
+#### MultisigAccount
+
+| Method | Description |
+|--------|-------------|
+| `id()` | Account ID |
+| `nonce()` | Current nonce |
+| `commitment()` | Account state commitment |
+| `threshold()` | Signing threshold |
+| `num_signers()` | Number of signers |
+| `cosigner_commitments()` | List of commitments (Word) |
+| `cosigner_commitments_hex()` | List as hex strings |
+| `is_cosigner(commitment)` | Check if commitment is signer |
+| `guardian_commitment()` | GUARDIAN server commitment |
+
+#### TransactionType
+
+| Variant | Description |
+|---------|-------------|
+| `P2ID { recipient, faucet_id, amount, note_type, heights }` | Transfer funds (`note_type` selects note visibility; `heights: P2ideHeights` with either constraint set creates a P2IDE note — use `transfer_p2ide()`; `transfer()` defaults to a public plain-P2ID note) |
+| `ConsumeNotes { note_ids }` | Consume notes |
+| `AddCosigner { new_commitment }` | Add signer |
+| `RemoveCosigner { commitment }` | Remove signer |
+| `UpdateSigners { new_threshold, signer_commitments }` | Update config |
+| `SwitchGuardian { new_endpoint, new_commitment }` | Switch GUARDIAN |
+
+#### ProposalStatus
+
+| Variant | Description |
+|---------|-------------|
+| `Pending` | Collecting sigs (`proposal.signature_counts()`, `proposal.metadata.signers`) |
+| `Ready` | Threshold met |
+| `Finalized` | Executed |
+
+---
+
+## Use Cases
+
+### Use Case 1: Treasury Management (2-of-3)
+
+A company treasury requiring 2 of 3 executives to approve transfers.
+
+```typescript
+// Setup: CEO, CFO, and COO each have their own signer
+const config = {
+ threshold: 2,
+ signerCommitments: [ceoCommitment, cfoCommitment, cooCommitment],
+ guardianCommitment,
+};
+
+const treasury = await client.create(config, ceoSigner);
+await treasury.registerOnGuardian();
+
+// CEO proposes payment to vendor
+const payment = await treasury.createP2idProposal(
+ vendorAccountId,
+ usdcFaucetId,
+ 50000n
+);
+
+// CFO reviews and signs
+const cfoMultisig = await cfoClient.load(treasury.accountId, cfoSigner);
+await cfoMultisig.syncProposals();
+await cfoMultisig.signProposal(payment.id);
+
+// Payment executes (threshold met: CEO + CFO = 2)
+await treasury.executeProposal(payment.id);
+```
+
+### Use Case 2: Secure Operations (3-of-5)
+
+High-security operations requiring 3 of 5 board members.
+
+```rust
+// Create 3-of-5 multisig
+let board_commitments = vec![member1, member2, member3, member4, member5];
+let account = client.create_account(3, board_commitments).await?;
+
+// Propose removing a compromised member
+let tx = TransactionType::remove_cosigner(compromised_member);
+let proposal = client.propose_transaction(tx).await?;
+
+// Three members must sign
+// member1.sign_proposal(...)
+// member2.sign_proposal(...)
+// member3.sign_proposal(...)
+
+// Execute with 3 signatures
+client.execute_proposal(&proposal.id).await?;
+```
+
+### Use Case 3: Note Consumption
+
+Claiming tokens sent to the multisig.
+
+```typescript
+// Check for incoming notes
+const notes = await multisig.getConsumableNotes();
+
+console.log('Pending notes:');
+for (const note of notes) {
+ for (const asset of note.assets) {
+ if (asset.isFungible()) {
+ console.log(` ${note.id}: ${asset.amount()} from faucet ${asset.faucetId()}`);
+ }
+ }
+}
+
+// Create proposal to consume all notes
+const noteIds = notes.map(n => n.id);
+const proposal = await multisig.createConsumeNotesProposal(noteIds);
+
+// After threshold signatures...
+await multisig.executeProposal(proposal.id);
+
+console.log('Notes consumed, funds now in vault');
+```
+
+---
+
+## Offline Workflow
+
+### Complete Flow Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ OFFLINE SIGNING FLOW │
+└─────────────────────────────────────────────────────────────────────┘
+
+ PROPOSER (Online) COSIGNER (Air-gapped) EXECUTOR (Online)
+ ───────────────── ──────────────────── ────────────────
+ │ │ │
+ │ create_proposal_offline() │ │
+ │ or propose_with_fallback() │ │
+ ▼ │ │
+ ┌──────────┐ │ │
+ │ Export │ │ │
+ │ proposal │ │ │
+ │ .json │─────── USB ─────────►│ │
+ └──────────┘ │ │
+ │ ▼ │
+ │ ┌──────────────┐ │
+ │ │ Import JSON │ │
+ │ │ Sign offline │ │
+ │ │ Export JSON │ │
+ │ └──────────────┘ │
+ │ │ │
+ │ │─────── USB ───────────────►│
+ │ │ ▼
+ │ │ ┌─────────────┐
+ │ │ │ Import JSON │
+ │ │ │ Verify sigs │
+ │ │ │ Execute tx │
+ │ │ └─────────────┘
+ ▼ ▼ ▼
+```
+
+### Export Format (JSON)
+
+```json
+{
+ "version": 1,
+ "account_id": "0x7925bdcc9c4df01068e79d4c94beeb",
+ "id": "0xabcd1234...",
+ "nonce": 5,
+ "tx_summary": {
+ "...": "transaction summary JSON"
+ },
+ "signatures": [
+ {
+ "signer_commitment": "0x1234...",
+ "signature": "0x5678..."
+ }
+ ],
+ "signatures_required": 2,
+ "metadata": {
+ "proposal_type": "add_signer",
+ "salt_hex": "0x...",
+ "new_threshold": 2,
+ "signer_commitments_hex": ["0x...", "0x..."]
+ }
+}
+```
+
+## Version Compatibility
+
+Which Miden protocol line each Guardian release targets, the exact `miden-client`
+and `@miden-sdk/miden-sdk` pins, what broke between lines, and which upgrades
+reset stored data: see
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md).
+
+Guardian's version and Miden's are not aligned (Guardian 0.16.x runs on Miden
+0.15; Miden 0.16 lands in Guardian 0.17.x), so read that matrix rather than
+matching the numbers. Per-release breaking changes are also in the
+[GitHub release notes](https://github.com/OpenZeppelin/guardian/releases).
+
+### Contract version pinning
+
+Accounts are built from the audited upstream `AuthGuardedMultisig` component, pinned
+exactly in both SDKs so a TypeScript-built account is byte-identical to a Rust-built
+one:
+
+- **Rust**: the `miden-standards` pin in the workspace `Cargo.toml`
+- **TypeScript**: the `@miden-sdk/miden-sdk` pin, whose bundled WASM embeds the
+ matching upstream `miden-standards` guarded-multisig component
+
+Matching versions is what makes the roots match, because neither SDK compiles the auth
+component any more: both ask `miden-standards` for its `AuthGuardedMultisig` and use what
+it returns. That settles the linkage question that used to matter here. `auth_tx` calls
+`miden::standards::fee`, so its root depends on whether the standards package is linked
+statically (the callee's MAST is inlined) or dynamically (an external reference is left) —
+compiling an equivalent source locally links dynamically and roots differently, which is
+why a locally built component could not be classified by `AccountComponentInterface::from_procedures`
+and never had fee conversion info attached. Taking the component from upstream removes the
+choice, so the pinned roots describe upstream's build and nothing else.
+
+The exact versions for each Guardian release are in
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md#support-matrix); they are not
+repeated here so there is one place to update.
+
+The pins are deliberate and must move together: nothing at build time verifies the
+npm SDK's embedded miden-standards matches the Rust pin — the CI parity gates
+(`procedure_roots_match_upstream_component`, the vitest `procedure-roots` test, and
+the Playwright determinism spec) are what catch drift.
+
+**Deployed accounts are immutable.** An account's code — and therefore its procedure
+roots — is fixed at creation. The SDK's hardcoded `PROCEDURE_ROOTS` /
+`ProcedureName::root()` values, and the transaction scripts it compiles against the
+bundled library, all assume the account was built from the *currently pinned*
+contract version. Consequences of bumping the pin to a miden-standards release whose
+MASM changed:
+
+- Management transactions built by the new SDK **fail against old accounts** (the
+ script calls a procedure root the old account's code does not export).
+- Per-procedure threshold reads and `set_procedure_threshold` writes key the
+ account's `procedure_thresholds` storage map by the *new* roots — against an old
+ account they silently miss the stored overrides or store overrides the account
+ never consults.
+
+**Release policy until a contract-version registry lands**: treat any
+miden-standards / @miden-sdk pin bump that changes procedure roots as a breaking
+release. Bump the minor version, regenerate the root constants (both SDKs), and
+state explicitly in the release notes that the new SDK operates only accounts
+created with the new contract version. The planned fix is a version registry keyed
+by the account's auth-procedure root, letting one SDK operate accounts from every
+supported contract version.
+
+#### SDK ↔ contract version support
+
+An SDK release operates only accounts created with its pinned contract version;
+the mapping is in
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md#support-matrix).
+
+Compatibility there is about the on-chain account, not Guardian's stored state.
+Adopting a new Miden line has twice required an irreversible server-side reset, so
+even an account whose contract version still matches must be re-registered
+afterwards. See
+[`MIDEN_COMPATIBILITY.md`](../miden-compatibility.md#data-resets).
+
+Both SDKs **enforce** this at runtime rather than trusting the table: before any
+procedure-root-keyed storage read, the account's code is checked for the pinned
+contract version's auth procedure (`auth_tx_guarded_multisig`). A mismatch fails
+loudly — Rust `MultisigError::UnsupportedContractVersion` (from
+`MultisigAccount::procedure_threshold` and everything built on it), TS an
+`unsupported contract version` error from `AccountInspector.fromAccount` — instead
+of silently reporting wrong thresholds. `MultisigAccount::is_pinned_contract_version()`
+exposes the check directly. When a new contract version is adopted, add a row here
+and regenerate the root constants in the same change.
+
+---
+
+## Releasing
+
+Steps for publishing a new version of the SDK (Rust crates + TypeScript packages).
+
+### Pre-Release Checklist
+
+1. All tests pass:
+
+```bash
+# Rust
+cargo test --locked \
+ -p guardian-shared \
+ -p guardian-client \
+ -p miden-confidential-contracts \
+ -p miden-multisig-client
+
+# TypeScript
+cd packages
+npm ci
+npm test -w @openzeppelin/guardian-client
+npm test -w @openzeppelin/guardian-evm-client
+npm test -w @openzeppelin/guardian-operator-client
+npm run build -w @openzeppelin/guardian-client
+npm test -w @openzeppelin/miden-multisig-client
+```
+
+2. TypeScript packages build cleanly:
+
+```bash
+cd packages
+npm run build -w @openzeppelin/guardian-client
+npm run build -w @openzeppelin/guardian-evm-client
+npm run build -w @openzeppelin/guardian-operator-client
+npm run build -w @openzeppelin/miden-multisig-client
+```
+
+3. Version numbers are updated in all files (see below).
+
+4. The coordinated Rust publication archive passes:
+
+```bash
+cargo publish --dry-run --locked \
+ -p guardian-shared \
+ -p guardian-client \
+ -p miden-confidential-contracts \
+ -p miden-multisig-client
+```
+
+### Version Bump
+
+Update the version in these files:
+
+| File | Field | Inherits |
+|------|-------|----------|
+| `Cargo.toml` (workspace root) | `[workspace.package] version` | `shared`, `client`, `contracts`, `miden-multisig-client` |
+| `crates/contracts/Cargo.toml` | `guardian-shared` dep version | - |
+| `crates/client/Cargo.toml` | `guardian-shared` dep version | - |
+| `crates/miden-multisig-client/Cargo.toml` | `guardian-client`, `guardian-shared`, `miden-confidential-contracts` dep versions | - |
+| `packages/guardian-client/package.json` | `version` | - |
+| `packages/guardian-evm-client/package.json` | `version` | - |
+| `packages/guardian-operator-client/package.json` | `version` | - |
+| `packages/miden-multisig-client/package.json` | `version` + `@openzeppelin/guardian-client` dep version | - |
+| `packages/package-lock.json` | workspace lockfile | refresh with `npm install` in `packages/` |
+
+The `server`, `miden-rpc-client`, `miden-keystore`, and example crates have their own independent versions and are not published.
+
+After bumping TypeScript versions, refresh the workspace lockfile:
+
+```bash
+cd packages
+npm install
+```
+
+The lockfile records a workspace link for `@openzeppelin/guardian-client`, not an npm tarball, so it does not need the new client version to exist on the registry.
+
+### Publishing Rust Crates
+
+Rust crates are published by the
+[`Publish Rust Crates`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/.github/workflows/publish-crates.yml) workflow.
+Publishing a GitHub Release automatically selects all four crates, validates
+that the tag is `v` followed by their coordinated version, requests one
+`release` environment approval, and uses crates.io trusted publishing. Cargo
+receives all selected packages in one command and handles dependency-safe
+publication.
+
+Configure a crates.io trusted publisher for each crate before the first
+automated publication:
+
+```text
+GitHub owner: OpenZeppelin
+Repository: guardian
+Workflow: publish-crates.yml
+Environment: release
+```
+
+Use a manual dry run to validate one or more selected crates without requesting
+approval or credentials:
+
+```bash
+gh workflow run publish-crates.yml \
+ --ref main \
+ -f dry-run=true \
+ -f guardian-shared=true \
+ -f guardian-client=true \
+ -f miden-confidential-contracts=true \
+ -f miden-multisig-client=true
+```
+
+A manual run with `dry-run=false` publishes only the selected crates after one
+approval. If an unselected internal prerequisite is not already visible at the
+exact coordinated version, validation fails before publication.
+
+Publication is rerunnable. Exact versions already on crates.io are reported as
+already published and omitted from the Cargo command. If a publication is
+interrupted, rerun it; the preflight skips versions that reached crates.io and
+Cargo processes the remainder. Trusted-publishing failure fails closed; the
+workflow has no registry-token fallback.
+
+### Publishing TypeScript Packages
+
+Packages live in the `packages/` npm workspace. Install once from that directory, then publish in dependency order. `miden-multisig-client` links the in-repo `@openzeppelin/guardian-client`; the published `package.json` still carries the `^` version range for consumers.
+
+```bash
+cd packages
+npm ci
+
+# 1. Build TypeScript packages
+npm run build -w @openzeppelin/guardian-client
+npm run build -w @openzeppelin/guardian-evm-client
+npm run build -w @openzeppelin/guardian-operator-client
+npm run build -w @openzeppelin/miden-multisig-client
+
+# 2. Publish base clients first (no internal deps)
+npm publish -w @openzeppelin/guardian-client --access public
+npm publish -w @openzeppelin/guardian-evm-client --access public
+npm publish -w @openzeppelin/guardian-operator-client --access public
+
+# 3. Publish miden-multisig-client (depends on guardian-client)
+npm publish -w @openzeppelin/miden-multisig-client --access public
+```
+
+Publishing a GitHub release runs the `Publish NPM Packages` workflow for all
+four packages. A manual workflow run can select any subset of the four package
+inputs; at least one package must be selected. The `dry-run` input applies only
+to that selected subset. All selected packages run serially in one protected
+`release` environment job, so the workflow requires one approval.
+
+### Post-Release
+
+1. Create a draft GitHub Release for review:
+
+```bash
+gh release create v --generate-notes --draft
+```
+
+2. Publish the draft when the coordinated Rust, TypeScript, and server-image
+ release workflows should start:
+
+```bash
+gh release edit v --draft=false
+```
+
+3. Approve the `release` environment jobs that should publish. The Rust
+ workflow summary records the source SHA, coordinated version,
+ authentication mode, and ordered per-crate outcomes without credentials.
+
+---
+
+## Additional Resources
+
+- [Miden Documentation](https://docs.miden.io/)
+- [GUARDIAN Documentation](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/README.md)
+ - [GUARDIAN Specification](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/index.md)
+- [Example Applications](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/)
+ - [Web Example](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/web/)
+ - [CLI Demo](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/demo/)
+ - [Rust Example](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/examples/rust/)
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/reference/openapi.md b/versioned_docs/version-0.16/builder/miden-guardian/reference/openapi.md
new file mode 100644
index 00000000..94a9627e
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/reference/openapi.md
@@ -0,0 +1,68 @@
+---
+title: "OpenAPI specification"
+sidebar_position: 2
+---
+Guardian's HTTP API is described by an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0)
+specification generated directly from the server source with
+[`utoipa`](https://docs.rs/utoipa). Because the spec is derived from the
+same `#[utoipa::path]` annotations and `#[derive(ToSchema)]` models the
+handlers use, it cannot drift from the implementation — and CI fails if
+the committed files fall out of sync (see below).
+
+## Surfaces and files
+
+Guardian exposes two HTTP surfaces — the **client** API (tag `client`)
+consumed by SDKs/packages and the operator **dashboard** API (tag
+`dashboard`) — plus the feature-gated EVM API (tag `evm`). Four specs are
+committed under [`docs/`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs), generated with the `evm` feature so they
+document every route:
+
+| File | Contents | Maps to |
+| --- | --- | --- |
+| [`openapi.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/openapi.json) | combined client + dashboard + evm | served at runtime |
+| [`openapi-client.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/openapi-client.json) | client API only | `packages/guardian-client` |
+| [`openapi-dashboard.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/openapi-dashboard.json) | dashboard API only | `packages/guardian-operator-client` |
+| [`openapi-evm.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/docs/openapi-evm.json) | EVM API only | `packages/guardian-evm-client` |
+
+Splitting per surface keeps SDK generation scoped and avoids exposing
+unrelated schemas to a given client.
+
+**Served at runtime:** `GET /api-docs/openapi.json` returns the combined
+spec for the routes the running binary actually mounts (EVM routes appear
+only when the server is built with `--features evm`).
+
+Point any OpenAPI tooling — Swagger UI, ReDoc, or a client-SDK
+generator — at any of these.
+
+## Authentication
+
+The specs declare security schemes so tools render auth correctly:
+
+- **Client API** — three required `apiKey` headers, `x-pubkey`,
+ `x-signature`, `x-timestamp` (see [`spec/api.md`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/spec/api.md)
+ "Miden Request Signing"). Public endpoints (`/pubkey`, `/status`, and
+ its root alias `/`) carry no requirement.
+- **Dashboard API** — the `guardian_operator_session` cookie
+ (`operator_session`). The login challenge/verify endpoints are public.
+- **EVM API** — the `guardian_evm_session` cookie (`evm_session`). The
+ challenge/verify endpoints are public.
+
+## Regenerating the checked-in files
+
+Run the `gen-openapi` binary with the `evm` feature, writing into
+`docs/`:
+
+```sh
+cargo run --features evm --bin gen-openapi -- docs
+```
+
+To verify the committed files are current without writing (what CI runs):
+
+```sh
+cargo run --features evm --bin gen-openapi -- --check docs
+```
+
+Regenerate and commit the specs whenever you add or change an HTTP
+handler, its request/response/query/path types, auth behavior, or a
+model that appears on the wire — the same way the proto contract is kept
+in sync. The `OpenAPI Spec Drift` CI job enforces this.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/_category_.json b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/_category_.json
new file mode 100644
index 00000000..b3118ef3
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Runbooks",
+ "position": 6
+}
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/backup-restore.md b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/backup-restore.md
new file mode 100644
index 00000000..4756f101
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/backup-restore.md
@@ -0,0 +1,370 @@
+---
+title: "Database Backup and Restore Runbook"
+---
+Operational guide for verifying that the Guardian RDS database is backed up
+and for restoring it after data loss. Companion to the durability overview in
+[`PRODUCTION.md`](../operations/production.md#durability-and-recovery) (what is
+guaranteed) and [`architecture/infra.md`](../architecture/infra.md) (how the
+stack fits together).
+
+> **Audience:** operators with AWS (RDS, ECS, Secrets Manager) and Terraform
+> access for the target Guardian stack.
+
+All Guardian state lives in the stack's RDS instance
+(`-postgres`) — account state, deltas, proposals, account metadata,
+and audit rows. The server tasks are stateless; nothing else needs to be
+restored.
+
+## What the stack backs up for you
+
+- **Automated backups.** A daily snapshot plus continuous WAL archiving,
+ retained for `rds_backup_retention_days` (default 7). This enables restore
+ to any second within the window, up to `LatestRestorableTime` (typically
+ within ~5 minutes of now).
+- **Final snapshot on destroy.** In the prod stage,
+ `./scripts/aws-deploy.sh cleanup` takes a final snapshot named
+ `-postgres-final` before deleting the instance.
+- **Deletion protection.** In the prod stage the instance refuses deletion
+ until `rds_deletion_protection` is explicitly turned off.
+
+What it does **not** back up:
+
+- **Terraform state.** State files are local to the deploy host (see
+ [`architecture/infra.md`](../architecture/infra.md#things-that-are-deliberately-not-here)).
+- **Secrets Manager values.** The stack-managed `DATABASE_URL` secret is
+ recreated by Terraform, but the ACK signing keys and — critically — the
+ **storage encryption key** are not derivable from anything. If storage
+ encryption is enabled, a database backup without the key is ciphertext:
+ keep an out-of-band copy of the key secret per
+ [`secrets.md`](./secrets.md).
+
+## Verify backups (do this now, not during an incident)
+
+```bash
+aws rds describe-db-instances \
+ --db-instance-identifier -postgres \
+ --query 'DBInstances[0].{Retention:BackupRetentionPeriod,DeletionProtection:DeletionProtection,MultiAZ:MultiAZ,LatestRestorableTime:LatestRestorableTime}'
+
+aws rds describe-db-snapshots \
+ --db-instance-identifier -postgres \
+ --query 'DBSnapshots[*].{Id:DBSnapshotIdentifier,Created:SnapshotCreateTime,Status:Status}'
+```
+
+For a prod stack, expect `Retention >= 7`, `DeletionProtection: true`, a
+recent `LatestRestorableTime`, and at least one `available` snapshot. If
+retention is `0`, backups are **off** — fix the stack before anything else.
+
+## Restore procedure (stack intact)
+
+This is the path for data loss while the stack still exists — bad data was
+written, a migration went wrong, the instance failed. If the whole stack was
+destroyed, start at
+[Full-stack recovery](#full-stack-recovery-after-a-destroy) instead; it
+recreates the prerequisites these steps reference and then re-enters here.
+
+RDS restores never overwrite an instance in place: both point-in-time and
+snapshot restores create a **new** instance. The stack's `DATABASE_URL`
+secret is derived by Terraform from the database endpoint (in prod, the RDS
+Proxy endpoint, whose target is the instance), and endpoints follow the
+instance **identifier**. So the procedure is: restore to a temporary
+identifier, then swap names so the restored instance answers at the original
+endpoint, then point Terraform state at the restored instance and re-apply.
+The state step is not optional: the AWS provider (v5) tracks
+`aws_db_instance` by its **DbiResourceId**, which survives renames — after
+the swap, Terraform still tracks the renamed-away old instance, and an apply
+without the state fix would try to rename it back onto the restored one.
+Step 4 covers it.
+
+### 1. Stop writes
+
+Scale the server to zero so no deltas land while the database is swapped.
+On a stack with ECS autoscaling (the prod default), pin the autoscaling
+range first or it will immediately scale back up:
+
+```bash
+aws application-autoscaling register-scalable-target \
+ --service-namespace ecs \
+ --resource-id service/-cluster/-server \
+ --scalable-dimension ecs:service:DesiredCount \
+ --min-capacity 0 --max-capacity 0
+
+aws ecs update-service --cluster -cluster \
+ --service -server --desired-count 0
+```
+
+On a dev stack autoscaling is off by default — skip the
+`register-scalable-target` call (it would create a scaling target Terraform
+does not manage) and run only `update-service`.
+
+The final `terraform apply` in step 4 restores both settings.
+
+### 2. Restore to a temporary identifier
+
+Point-in-time (preferred — pick the moment just before the loss):
+
+```bash
+aws rds restore-db-instance-to-point-in-time \
+ --source-db-instance-identifier -postgres \
+ --target-db-instance-identifier -postgres-restored \
+ --restore-time 2026-08-04T12:00:00Z \
+ --db-subnet-group-name -postgres-subnets \
+ --vpc-security-group-ids
+```
+
+Or from a specific snapshot (a daily automated one, or the final snapshot
+during [full-stack recovery](#full-stack-recovery-after-a-destroy)):
+
+```bash
+aws rds restore-db-instance-from-db-snapshot \
+ --db-instance-identifier -postgres-restored \
+ --db-snapshot-identifier \
+ --db-subnet-group-name -postgres-subnets \
+ --vpc-security-group-ids
+```
+
+Pass the subnet group and security group explicitly — restores do **not**
+inherit them from the source. Find the security group ID with:
+
+```bash
+aws ec2 describe-security-groups \
+ --filters Name=group-name,Values=-postgres-sg \
+ --query 'SecurityGroups[0].GroupId' --output text
+```
+Wait for the instance to become `available`
+(`aws rds wait db-instance-available --db-instance-identifier -postgres-restored`).
+
+### 3. Swap identifiers
+
+```bash
+aws rds modify-db-instance --db-instance-identifier -postgres \
+ --new-db-instance-identifier -postgres-old --apply-immediately
+aws rds wait db-instance-available --db-instance-identifier -postgres-old
+
+aws rds modify-db-instance --db-instance-identifier -postgres-restored \
+ --new-db-instance-identifier -postgres --apply-immediately
+aws rds wait db-instance-available --db-instance-identifier -postgres
+```
+
+Skip the first rename if the original instance is already gone. Deletion
+protection blocks deletion, not renames, so this works on a prod instance.
+
+A rename changes the instance's DNS endpoint, and propagation can take up to
+~10 minutes after the instance reports `available`. Before moving on,
+confirm the canonical endpoint resolves:
+
+```bash
+dig +short $(aws rds describe-db-instances \
+ --db-instance-identifier -postgres \
+ --query 'DBInstances[0].Endpoint.Address' --output text)
+```
+
+### 4. Reconcile Terraform and restart the service
+
+Point Terraform state at the restored instance first. The provider tracks
+the database by DbiResourceId, which followed the old instance through its
+rename — without this step, an apply tries to rename `-postgres-old`
+back over the restored instance and fails. The state file lives at
+`infra/terraform...tfstate` (unless `TF_STATE_PATH` was
+overridden):
+
+```bash
+terraform -chdir=infra state rm \
+ -state=terraform...tfstate aws_db_instance.postgres
+
+terraform -chdir=infra import \
+ -state=terraform...tfstate \
+ -var stack_name= -var deployment_stage= \
+ -var aws_region= -var server_image_uri=unused \
+ aws_db_instance.postgres -postgres
+```
+
+(`server_image_uri` is the only variable without a default; import never
+reads it, but Terraform requires a value to evaluate the configuration.)
+
+Then verify the plan shows only in-place updates to the database (no
+destroy, no rename) before applying:
+
+```bash
+./scripts/aws-deploy.sh plan
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+The apply re-registers the RDS Proxy target, restores deletion protection
+and other instance settings Terraform manages, refreshes the `DATABASE_URL`
+secret, and returns the ECS service and autoscaling to their configured
+capacity. On a prod stack, confirm the proxy target is healthy:
+
+```bash
+aws rds describe-db-proxy-targets --db-proxy-name -postgres-proxy
+```
+
+### 5. Validate
+
+```bash
+./scripts/aws-deploy.sh status
+curl https:///
+curl https:///pubkey
+```
+
+Then run the relevant SDK or dashboard smoke path from
+[`PRODUCTION.md`](../operations/production.md#production-checklist).
+
+### 6. Clean up
+
+Keep `-postgres-old` until the restored stack has been validated in
+real use. Then snapshot and delete it:
+
+```bash
+aws rds modify-db-instance --db-instance-identifier -postgres-old \
+ --no-deletion-protection --apply-immediately
+aws rds wait db-instance-available --db-instance-identifier -postgres-old
+aws rds delete-db-instance --db-instance-identifier -postgres-old \
+ --final-db-snapshot-identifier -postgres-old-final
+```
+
+## Full-stack recovery (after a destroy)
+
+`./scripts/aws-deploy.sh cleanup` destroys everything Terraform manages —
+not just the database but also the subnet group, security group, RDS Proxy,
+ECS service, and the Terraform state entries for all of them. The final
+snapshot (`-postgres-final`) survives, but automated backups are
+deleted along with the instance, so point-in-time recovery is no longer
+available. The stack-intact procedure cannot run against a
+destroyed stack: its commands reference resources that no longer exist, and
+a plain redeploy would try to *create* a database, not adopt a restored one.
+
+Recover in two moves:
+
+### 1. Recreate the stack with no running tasks
+
+```bash
+TF_VAR_server_desired_count=0 \
+TF_VAR_server_autoscaling_max_capacity=0 \
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+This recreates every prerequisite — subnet group, security group, proxy,
+secrets, ECS service — plus a **fresh, empty** database at the canonical
+identifier, all tracked in Terraform state again. `--skip-build` reuses the
+ECR image, which survives cleanup (ECR is not Terraform-managed); drop the
+flag if the image is gone too. The two overrides pin both the desired count
+and the autoscaling ceiling to zero tasks (desired count alone is not
+enough — in prod the ceiling would default to 6 and target tracking could
+start tasks against the empty database before the swap).
+
+### 2. Restore from the final snapshot
+
+Run [stack-intact step 2](#2-restore-to-a-temporary-identifier) using the
+snapshot path with `--db-snapshot-identifier -postgres-final`.
+
+### 3. Reconcile the master password
+
+Unless the stack pins `postgres_password`, the master password is a
+Terraform-generated random value **per state file** — the redeploy in step 1
+minted a new one, while the snapshot carries the old one. Every credential
+Terraform writes (the `DATABASE_URL` secret, the proxy auth secret) uses the
+new value, so the restored instance must be reset to match before the server
+can log in:
+
+```bash
+NEW_PASSWORD=$(aws secretsmanager get-secret-value \
+ --secret-id /server/database-url \
+ --query SecretString --output text | sed -E 's|^postgres://[^:]+:([^@]+)@.*|\1|')
+
+aws rds modify-db-instance \
+ --db-instance-identifier -postgres-restored \
+ --master-user-password "$NEW_PASSWORD" --apply-immediately
+aws rds wait db-instance-available \
+ --db-instance-identifier -postgres-restored
+```
+
+The generated password is alphanumeric-only, so extracting it from the URL
+needs no decoding. If the stack pins `postgres_password` (same value across
+deploys), the passwords already match and this step is a no-op.
+
+### 4. Swap and finish
+
+Continue the [stack-intact procedure](#restore-procedure-stack-intact) from
+step 3. Step 1 (stop writes) is already satisfied, and the fresh empty
+instance plays the role of the original: it gets renamed to
+`-postgres-old` and eventually deleted in cleanup. Run the step 4
+re-apply **without** the two `TF_VAR_server_*` overrides so the service and
+autoscaling return to their configured capacity.
+
+## After the restore: Guardian-level reconciliation
+
+A point-in-time restore rewinds Guardian's stored state. Infrastructure-level
+loss is bounded by WAL shipping (~5 minutes), but any delta that
+canonicalized **on-chain** after the restore point cannot be regenerated by
+the guardian: guarded accounts are private on Miden, so the chain holds only
+a commitment hash and the full state lives on client devices.
+
+Accounts whose on-chain state advanced past the restored database state fail
+state-commitment verification until a client device holding the newer state
+re-syncs it — the "Guardian database corruption" row in the
+[`CONCEPTS.md` failure table](../getting-started/concepts.md#failure-and-recovery). Accounts
+onboarded after the restore point disappear from the guardian entirely:
+requests fail with `account_not_found` (HTTP 404, gRPC `NOT_FOUND`) rather than
+failing verification. A device holding the account re-onboards via
+`/configure`, which re-registers it with the state that device currently holds;
+other cosigner devices then sync from the guardian. Accounts with no
+post-restore-point activity are unaffected.
+
+## Logical backups (optional)
+
+For copies that live outside the AWS account (offline archives, cross-region
+without a replica), take periodic `pg_dump` backups. Two constraints shape
+the procedure:
+
+- The instance is not publicly accessible, so `pg_dump` must run from inside
+ the VPC.
+- The Guardian runtime image ships `libpq5` only — **no `pg_dump`**
+ ([`Dockerfile`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/Dockerfile) `server-runner` stage) — so an ECS Exec
+ session into the server container needs the PostgreSQL client installed
+ first. The container runs as root and the install is ephemeral (gone when
+ ECS replaces the task), which is fine for a one-off dump.
+
+```bash
+TASK_ARN=$(aws ecs list-tasks --cluster -cluster \
+ --service-name -server --query 'taskArns[0]' --output text)
+aws ecs execute-command --cluster -cluster --task "$TASK_ARN" \
+ --container -server --interactive --command /bin/bash
+```
+
+The client's major version must be **at least** the server's — `pg_dump`
+refuses servers newer than itself, and Debian bookworm's default
+`postgresql-client` is 15 while the stack's engine is whatever AWS defaulted
+to at creation (`rds_engine_version` is unpinned). Check the engine first:
+
+```bash
+aws rds describe-db-instances --db-instance-identifier -postgres \
+ --query 'DBInstances[0].EngineVersion' --output text
+```
+
+Inside the session, install the matching client major from the PGDG
+repository (substitute the major version from the check above), then dump
+using the `DATABASE_URL` already present in the container environment:
+
+```bash
+apt-get update && apt-get install -y postgresql-common awscli
+/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
+apt-get install -y postgresql-client-
+
+pg_dump "$DATABASE_URL" --format=custom --file=/tmp/guardian-$(date +%F).dump
+```
+
+To get the dump out, paste short-lived operator credentials into the session
+(the task role deliberately has no S3 grant) and upload:
+
+```bash
+export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=...
+aws s3 cp /tmp/guardian-*.dump s3:///
+```
+
+Alternatively, run a one-off Fargate task from the public `postgres:`
+image, attached to the server task's security group, with the same
+`DATABASE_URL` secret — avoids touching a serving container at the cost of
+registering a task definition.
+
+If storage encryption is enabled, remember the dump is ciphertext without
+the key secret.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/enable-db-tls.md b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/enable-db-tls.md
new file mode 100644
index 00000000..27939192
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/enable-db-tls.md
@@ -0,0 +1,189 @@
+---
+title: "Enabling verified database TLS Runbook"
+sidebar_position: 2
+---
+Operational guide for migrating an already-deployed Guardian stack from
+encrypted-only (`sslmode=require`) to **verified** database TLS
+(`sslmode=verify-full`). Companion to
+[`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md#database-tls-verification) (which
+explains the mechanism) and [Database TLS](../reference/configuration.md#database-tls) (the
+authoritative meaning of `sslmode`/`sslrootcert`).
+
+> **Audience:** operators with AWS Secrets Manager and ECS/Terraform write access
+> for the target Guardian stack.
+
+## Why this is safe
+
+- **Opt-in.** Until `rds_ca_bundle_secret_arn` is set, nothing changes —
+ `DATABASE_URL` stays `sslmode=require`. Setting it is the only trigger.
+- **Fail-closed, not service-down.** The service runs
+ `deployment_minimum_healthy_percent = 100` behind an ALB health check. If a
+ verifying task can't validate the certificate it exits and fails its health
+ check, so ECS **keeps the existing encrypt-only tasks serving** and the rollout
+ stalls. You fix the bundle and re-apply — no outage.
+- **One-variable rollback.** Blank `rds_ca_bundle_secret_arn` and `terraform
+ apply` to revert to `sslmode=require`.
+
+## The proxy-vs-direct gotcha (read before prod)
+
+`prod`/`testnet` route `DATABASE_URL` through the **RDS Proxy**, whose certificate
+is issued by **AWS Certificate Manager → Amazon Trust Services roots**. A
+**direct** RDS instance (the default in non-prod, where the proxy is
+`is_prod`-gated) instead presents the **Amazon RDS CA roots**.
+
+- The **combined bundle (region RDS roots + ATS roots)** covers both — which is
+ why it's mandatory.
+- But validating on a *direct* staging DB does **not** exercise the proxy's
+ cert/SAN path. Either temporarily enable the proxy on staging to mirror prod,
+ or treat the prod cutover as the first real proxy validation (with rollback
+ ready).
+
+## Pre-flight checklist
+
+- [ ] The deployed server image **includes this feature** (older images don't
+ parse `verify-full`/`sslrootcert` and will fail to connect). Roll the new
+ image out first with verification still **off** (Step 0).
+- [ ] Combined CA bundle built for the stack's region and **< 64 KiB** (Secrets
+ Manager rejects larger; do **not** use `global-bundle.pem`).
+- [ ] You know the stack's topology (proxy vs direct) and have matched the test
+ plan to it.
+- [ ] Rollback understood: blank the variable and re-apply.
+
+## How these commands run
+
+This stack is driven by [`scripts/aws-deploy.sh`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh)
+(the same tool used in [`SERVER_AWS_DEPLOY.md`](../operations/server-aws-deploy.md)), not raw
+`terraform`. Target a stack with `STACK_NAME`/`DEPLOY_STAGE`, and pass the new
+variable through Terraform's standard `TF_VAR_` mechanism — the script forwards
+the environment to Terraform, which honors `TF_VAR_rds_ca_bundle_secret_arn`
+because that variable is declared in [`infra/variables.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/variables.tf).
+
+```bash
+export STACK_NAME=guardian-staging # your stack
+export DEPLOY_STAGE=staging # dev | staging | testnet | prod
+```
+
+`deploy` builds + pushes a new image then applies; `deploy --skip-build` applies
+Terraform against the **already-deployed** image (use this for the verification
+flip, which needs no new image); `plan` previews; `status` / `logs` inspect.
+
+## Step 0 — Roll out the feature image with verification OFF
+
+Deploy the image containing verified DB TLS **without** setting
+`rds_ca_bundle_secret_arn`. The stack stays `sslmode=require` but becomes
+*capable* of verifying. This separates "new image" from "new verification" so a
+regression is attributable to one change, not both.
+
+```bash
+# from a checkout that includes this feature
+./scripts/aws-deploy.sh deploy
+./scripts/aws-deploy.sh status # confirm the new tasks are healthy
+```
+
+## Step 1 — Build the combined CA secret (per region)
+
+There is no `aws-deploy.sh` command for this (it's not an ACK secret), so create
+it directly. The name is stack-scoped, matching the ACK convention:
+
+```bash
+REGION=us-east-1 # the stack's region
+
+curl -sS "https://truststore.pki.rds.amazonaws.com/${REGION}/${REGION}-bundle.pem" -o rds.pem
+: > ats.pem
+for ca in AmazonRootCA1 AmazonRootCA2 AmazonRootCA3 AmazonRootCA4 SFSRootCAG2; do
+ curl -sS "https://www.amazontrust.com/repository/${ca}.pem" >> ats.pem; echo >> ats.pem
+done
+{ cat rds.pem; echo; cat ats.pem; } > rds-combined-ca.pem
+test "$(wc -c < rds-combined-ca.pem)" -lt 65536 || { echo "bundle exceeds 64 KiB"; exit 1; }
+grep -c "BEGIN CERTIFICATE" rds-combined-ca.pem # sanity: total root count
+
+aws secretsmanager create-secret \
+ --name "${STACK_NAME}/server/rds-ca-bundle" \
+ --secret-string file://rds-combined-ca.pem \
+ --query ARN --output text # note the ARN for Step 2
+```
+
+Create the secret **before** the apply that references its ARN.
+
+## Step 2 — Flip the stack to verify-full
+
+Point the variable at the secret ARN from Step 1 and preview, then apply against
+the existing image (no rebuild needed — this is a config-only change):
+
+```bash
+export TF_VAR_rds_ca_bundle_secret_arn="arn:aws:secretsmanager:::secret:/server/rds-ca-bundle-XXXXXX"
+
+./scripts/aws-deploy.sh plan # review
+./scripts/aws-deploy.sh deploy --skip-build # apply against the deployed image
+```
+
+The plan should show: an `rds-ca-initializer` init container added; a shared
+volume + read-only mount + `dependsOn { condition = SUCCESS }` on the server
+container; an execution-role `GetSecretValue` grant for the secret ARN; and
+`DATABASE_URL` changing to `…&sslmode=verify-full&sslrootcert=`.
+
+> Keep `TF_VAR_rds_ca_bundle_secret_arn` exported for every subsequent
+> `aws-deploy.sh` invocation on this stack (or set it in the stack's tfvars), so
+> later deploys don't silently revert the stack to `sslmode=require`.
+
+## Step 3 — Validate
+
+```bash
+./scripts/aws-deploy.sh status # the new deployment should reach a healthy/steady state
+./scripts/aws-deploy.sh logs # init container exited 0; no cert error; migrations ran; server listening
+```
+
+Expected: `rds-ca-initializer` exits 0 → Postgres reachable → migrations applied → HTTP
+listening, with **no** `certificate verify failed`. A crash-looping new task with
+a cert error means the bundle or endpoint is wrong — the old encrypt-only tasks
+keep serving, so fix the secret and re-apply.
+
+## Step 4 — Promote to prod/testnet
+
+Repeat Steps 1–3 with `STACK_NAME`/`DEPLOY_STAGE` pointed at prod/testnet and a
+prod-scoped secret, in a low-traffic window. Because traffic flows through the
+**RDS Proxy**, this is where the ACM/ATS chain and the **proxy-endpoint SAN** are
+verified for the first time — watch the rollout (`aws-deploy.sh status`/`logs`)
+and keep the rollback ready.
+
+## Rollback
+
+```bash
+unset TF_VAR_rds_ca_bundle_secret_arn # or remove it from the stack tfvars
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+`DATABASE_URL` reverts to `sslmode=require`, the init container and mount are
+removed, and tasks roll back to encrypt-only. (A *failed* verifying deploy never
+displaces the healthy old tasks in the first place.)
+
+## Rotation (after cutover)
+
+CA roots rotate rarely, but when they do: update the secret value (it may hold
+old and new roots together for overlap), then force a redeploy so the init
+container rewrites the file. A value-only secret change doesn't alter the task
+definition, so `aws-deploy.sh deploy` won't roll tasks on its own — force it:
+
+```bash
+aws secretsmanager put-secret-value \
+ --secret-id "${STACK_NAME}/server/rds-ca-bundle" \
+ --secret-string file://rds-combined-ca.pem
+
+# cluster/service names follow the stack name; confirm via `aws-deploy.sh status`
+aws ecs update-service --cluster "${STACK_NAME}-cluster" \
+ --service "${STACK_NAME}-server" --force-new-deployment
+```
+
+Ensure the new roots are present before they become the only trusted ones.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| New task crash-loops with `certificate verify failed` | bundle doesn't cover the presented chain — proxy stack needs the **ATS roots**, direct needs the **RDS CA roots** (use the combined bundle) |
+| `create-secret` fails on size | used `global-bundle.pem`; switch to the **region** bundle + ATS roots (< 64 KiB) |
+| Startup error naming `sslrootcert` | init container didn't write the file — check its logs and the execution-role secret grant |
+| Server won't parse `verify-full` | image predates the feature — complete Step 0 first |
+
+See [`TROUBLESHOOTING.md`](../operations/troubleshooting.md#server-fails-to-start) for the
+full database-TLS failure-to-cause mapping.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/guardian-domain-migration.md b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/guardian-domain-migration.md
new file mode 100644
index 00000000..cf245eb2
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/guardian-domain-migration.md
@@ -0,0 +1,266 @@
+---
+title: "Guardian Domain Migration"
+---
+This runbook is only for the one-time OpenZeppelin hostname migration in
+[issue #341](https://github.com/OpenZeppelin/guardian/issues/341). Normal
+Guardian deployments should leave `ALIAS_SUBDOMAIN` unset.
+
+The migration keeps each legacy hostname available on the same ALB while the new
+canonical hostname is verified. Both names route directly; there is no hostname
+redirect.
+
+| Deployment | Network | Canonical `SUBDOMAIN` | Temporary `ALIAS_SUBDOMAIN` |
+|---|---|---|---|
+| Devnet | `MidenDevnet` | `guardian-devnet` | `guardian-stg` |
+| Testnet | `MidenTestnet` | `guardian-testnet` | `guardian` |
+
+The ACM certificate configured through `ACM_CERTIFICATE_ARN` must cover both
+names, or the legacy hostname's certificate must be supplied separately through
+`ALIAS_ACM_CERTIFICATE_ARN` for SNI.
+
+## Select the stack explicitly
+
+Load shared credentials and deployment settings from `.env`, then override every
+value that identifies the stack. Do not rely on the current values in `.env`
+when selecting a state file manually. The `GUARDIAN_NETWORK_TYPE` exports pin
+the network each stack already runs (the hosted devnet stack runs `MidenDevnet`,
+the testnet stack `MidenTestnet`) so a stale `.env` value cannot switch the
+server's network inside this domain-only apply. Both hosted records are already
+Cloudflare-proxied, so the export blocks also pin `CLOUDFLARE_PROXIED=true` to
+prevent this migration from changing their proxy mode.
+
+For testnet:
+
+```bash
+set -a && source .env && set +a
+
+export AWS_REGION=us-east-1
+export STACK_NAME=guardian-prod
+export DEPLOY_STAGE=prod
+export GUARDIAN_NETWORK_TYPE=MidenTestnet
+export DOMAIN_NAME=openzeppelin.com
+export SUBDOMAIN=guardian-testnet
+export ALIAS_SUBDOMAIN=guardian
+export CLOUDFLARE_PROXIED=true
+export ACM_CERTIFICATE_ARN="arn:aws:acm:us-east-1::certificate/"
+export TF_STATE_PATH="$(pwd)/infra/terraform.guardian-prod.prod.tfstate"
+```
+
+For devnet:
+
+```bash
+set -a && source .env && set +a
+
+export AWS_REGION=us-east-1
+export STACK_NAME=guardian
+export DEPLOY_STAGE=dev
+export GUARDIAN_NETWORK_TYPE=MidenDevnet
+export DOMAIN_NAME=openzeppelin.com
+export SUBDOMAIN=guardian-devnet
+export ALIAS_SUBDOMAIN=guardian-stg
+export CLOUDFLARE_PROXIED=true
+export ACM_CERTIFICATE_ARN="arn:aws:acm:us-east-1::certificate/"
+export TF_STATE_PATH="$(pwd)/infra/terraform.guardian.dev.tfstate"
+```
+
+`ACM_CERTIFICATE_ARN` must point at an issued certificate covering the canonical
+hostname. If that certificate does not also cover the legacy hostname, also
+export `ALIAS_ACM_CERTIFICATE_ARN` with the legacy hostname's existing
+certificate. Both certificates must be in the ALB's AWS region.
+
+Inspect the certificate status and subject alternative names before changing
+state or planning the deployment:
+
+```bash
+CANONICAL_FQDN="${SUBDOMAIN}.${DOMAIN_NAME}"
+LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
+
+aws acm describe-certificate \
+ --region "$AWS_REGION" \
+ --certificate-arn "$ACM_CERTIFICATE_ARN" \
+ --query 'Certificate.{Status:Status,SANs:SubjectAlternativeNames}' \
+ --output json
+
+if [ -n "${ALIAS_ACM_CERTIFICATE_ARN:-}" ]; then
+ aws acm describe-certificate \
+ --region "$AWS_REGION" \
+ --certificate-arn "$ALIAS_ACM_CERTIFICATE_ARN" \
+ --query 'Certificate.{Status:Status,SANs:SubjectAlternativeNames}' \
+ --output json
+fi
+```
+
+Stop unless every displayed certificate is `ISSUED`. With one certificate, its
+SAN list must cover both `$CANONICAL_FQDN` and `$LEGACY_FQDN`, either explicitly
+or through a matching wildcard. With split certificates, the canonical
+certificate must cover `$CANONICAL_FQDN` and the alias certificate must cover
+`$LEGACY_FQDN`.
+
+With a split-certificate setup, the apply swaps the listener's default
+certificate to the canonical one before it creates the SNI attachment for the
+legacy certificate, so TLS on the live legacy hostname can fail for a brief
+window during the apply. Prefer a single certificate covering both names; when
+`ALIAS_ACM_CERTIFICATE_ARN` is unavoidable, schedule the apply for a
+low-traffic window.
+
+Before continuing, authenticate and confirm that the selected state belongs to
+the intended deployment:
+
+```bash
+aws sts get-caller-identity
+terraform -chdir=infra output -state="$TF_STATE_PATH" deployment_stage
+terraform -chdir=infra output -state="$TF_STATE_PATH" ecs_cluster_name
+terraform -chdir=infra output -state="$TF_STATE_PATH" custom_domain_url
+```
+
+Stop if these outputs do not match the selected stack, stage, and legacy
+hostname.
+
+## Determine whether a state move is needed
+
+A state move is not part of a normal hostname addition and is unnecessary in
+most deployments. It is needed only when all of the following are true:
+
+- the legacy hostname is already managed by this Terraform state;
+- it is tracked at the primary DNS resource address; and
+- the same apply must make the new hostname primary while retaining the legacy
+ hostname as the secondary record.
+
+This is the expected shape of the existing OpenZeppelin devnet and testnet
+stacks. The move changes only Terraform's local ownership address; it does not
+modify live DNS.
+
+Inspect the selected state:
+
+```bash
+terraform -chdir=infra state list -state="$TF_STATE_PATH" |
+ rg '^(cloudflare_dns_record\.service|aws_route53_record\.service_alias)'
+```
+
+Skip the state move when the legacy record is not present at either primary
+address below. Examples include a fresh stack, DNS managed outside Terraform,
+or a state that has already been migrated. Do not import or move an unfamiliar
+record without first reconciling who owns it.
+
+If a primary address is present, back up the state:
+
+```bash
+chmod 600 "$TF_STATE_PATH"
+install -m 600 "$TF_STATE_PATH" "${TF_STATE_PATH}.before-domain-migration"
+```
+
+Move only the record type shown by `state list`; move both only if the stack
+actually manages both providers. Run each matching block separately. The
+subshell keeps a failed guard from terminating the operator's shell, and each
+block fails before the move unless the tracked record is the expected legacy
+hostname.
+
+For Cloudflare-managed DNS, provider state may store `name` as either the
+relative record name or the full hostname:
+
+```bash
+(
+ EXPECTED_LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
+ CURRENT_LEGACY_NAME=$(terraform -chdir=infra state show -state="$TF_STATE_PATH" \
+ 'cloudflare_dns_record.service[0]' |
+ sed -nE 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p')
+ case "$CURRENT_LEGACY_NAME" in
+ "$ALIAS_SUBDOMAIN"|"$EXPECTED_LEGACY_FQDN") ;;
+ *)
+ echo "Refusing state move: expected ${ALIAS_SUBDOMAIN} or ${EXPECTED_LEGACY_FQDN}, found ${CURRENT_LEGACY_NAME:-}" >&2
+ exit 1
+ ;;
+ esac
+ terraform -chdir=infra state mv -state="$TF_STATE_PATH" \
+ 'cloudflare_dns_record.service[0]' \
+ 'cloudflare_dns_record.service_secondary[0]'
+)
+```
+
+For Route 53-managed DNS, the record's alias block holds a second `name`
+attribute for the ALB, so the guard reads the top-level `fqdn` instead:
+
+```bash
+(
+ EXPECTED_LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
+ CURRENT_LEGACY_FQDN=$(terraform -chdir=infra state show -state="$TF_STATE_PATH" \
+ 'aws_route53_record.service_alias[0]' |
+ sed -nE 's/^[[:space:]]*fqdn[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p')
+ test "$CURRENT_LEGACY_FQDN" = "$EXPECTED_LEGACY_FQDN" || {
+ echo "Refusing state move: expected ${EXPECTED_LEGACY_FQDN}, found ${CURRENT_LEGACY_FQDN:-}" >&2
+ exit 1
+ }
+ terraform -chdir=infra state mv -state="$TF_STATE_PATH" \
+ 'aws_route53_record.service_alias[0]' \
+ 'aws_route53_record.service_secondary[0]'
+)
+```
+
+The hosted devnet and testnet states currently use Cloudflare rather than Route
+53, but always trust `terraform state list` for the selected state. After a
+move, run it again and confirm the record appears only at the corresponding
+`service_secondary[0]` address.
+
+When DNS is managed outside Terraform, skip both state-move blocks and create or
+retain the canonical and legacy records with that provider. Terraform still
+attaches the required ACM certificates to the ALB.
+
+## Plan, deploy, and verify
+
+Run the plan with the same `STACK_NAME`, `DEPLOY_STAGE`, `SUBDOMAIN`, and
+`ALIAS_SUBDOMAIN` used for the state move:
+
+```bash
+./scripts/aws-deploy.sh plan
+```
+
+For Terraform-managed DNS, the plan must retain the legacy secondary record and
+create the new canonical record. On the legacy Cloudflare record, only the
+in-place `comment` update is expected; stop if `proxied`, `name`, `content`, or
+any other behavior-affecting field changes. With external DNS, the plan should
+not add DNS resources; confirm both records separately with that provider.
+Certificate and output changes are expected in either case. Do not apply if the
+plan destroys or replaces the legacy DNS record, ALB, ECS service, or database.
+
+A wrong `.env` value surfaces as an in-place `aws_ecs_task_definition` update
+rather than a destroy or replace, so inspect that diff too. This domain-only
+apply must not change the task definition at all: an environment diff (network
+type, CORS origins, operator keys) means per-stack configuration leaked from
+`.env`, and an image diff means the ECR `latest` tag has moved since the
+stack's last deploy. Stop and pin the correct values before applying. After
+reviewing the plan:
+
+```bash
+./scripts/aws-deploy.sh deploy --skip-build
+```
+
+Verify HTTP and gRPC on both names; the `curl` and `grpcurl` calls each verify
+the certificate chain and hostname as part of the TLS handshake. This example
+shows testnet; repeat it for `guardian-devnet` and `guardian-stg`:
+
+```bash
+curl --fail-with-body https://guardian-testnet.openzeppelin.com/pubkey
+curl --fail-with-body https://guardian.openzeppelin.com/pubkey
+grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian-testnet.openzeppelin.com:443 guardian.Guardian/GetPubkey
+grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian.openzeppelin.com:443 guardian.Guardian/GetPubkey
+```
+
+Keep SDK, smoke-test, benchmark, and operational defaults on the legacy names
+during the observation period. Switch consumers in a follow-up change only after
+both canonical hostnames have been confirmed stable.
+
+## Remove the legacy hostname
+
+After consumers have moved and monitoring shows no required traffic on the
+legacy hostname, unset `ALIAS_SUBDOMAIN` and `ALIAS_ACM_CERTIFICATE_ARN` while
+keeping all canonical stack values pinned as above:
+
+```bash
+unset ALIAS_SUBDOMAIN ALIAS_ACM_CERTIFICATE_ARN
+./scripts/aws-deploy.sh plan
+```
+
+The cleanup plan must delete only the legacy DNS record, the optional secondary
+listener certificate attachment, and migration-only outputs. Apply it with
+`./scripts/aws-deploy.sh deploy --skip-build`, then verify the canonical HTTP and
+gRPC endpoints again.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/horizontal-scaling.md b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/horizontal-scaling.md
new file mode 100644
index 00000000..6884c58a
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/horizontal-scaling.md
@@ -0,0 +1,142 @@
+---
+title: "Runbook: Horizontal Scaling (multiple Guardian replicas)"
+---
+Guardian runs as 2–6 ECS tasks behind a round-robin load balancer in the prod
+profile. This runbook covers what an operator must configure for a correct
+high-availability (HA) deployment and how the server behaves across replicas.
+Tracking: issue #242.
+
+## TL;DR — required for a correct multi-replica deployment
+
+| Setting | Why it matters across replicas |
+|---|---|
+| **Postgres backend** (`DATABASE_URL`) | Sessions, login challenges, and the canonicalization lease live in Postgres so they are shared. The filesystem backend is **dev-only** and is refused at startup in the prod stage. |
+| **`GUARDIAN_DASHBOARD_CURSOR_SECRET`** (64 hex chars) | Pagination cursors are signed with this key. The prod Terraform profile injects one pre-created Secrets Manager value into every task. Outside that profile, an unset value makes the server **warn** and generate an ephemeral per-process secret; this degrades pagination across replicas, not custody. |
+| **`GUARDIAN_ENV=prod`** | Activates the prod-stage startup guards (filesystem-backend refusal, 0-req/replica rate-limit refusal). Set by Terraform from `var.deployment_stage`. |
+| **`GUARDIAN_MAX_REPLICAS`** | Rate-limit partitioning divisor (see below). Defaults from the autoscaling max capacity via Terraform. |
+
+With the published Postgres image + the prod Terraform profile, all of these are
+set for you after the one-time cursor-secret bootstrap:
+
+```bash
+DEPLOY_STAGE=prod STACK_NAME= ./scripts/aws-deploy.sh bootstrap-dashboard-cursor-secret
+```
+
+Normal deploys require that secret to exist and never create or rotate it. The
+rest of this doc is for understanding and for non-default deployments.
+
+## Coordination is backend-derived (not a tunable)
+
+The coordination mode is determined by the **storage backend alone**:
+
+- **Postgres backend → shared coordination** (replica-safe). Always. No tunable
+ can turn this off — a missing or wrong env var can never silently revert a
+ Postgres deployment to per-process state.
+- **Filesystem backend → in-memory, single-process** coordination (dev only).
+
+The startup log emits one line reflecting the resolved state, e.g.:
+
+```text
+coordination mode=shared backend=postgres stage=prod max_replicas=6 cursor_secret=configured
+```
+
+If you ever see `mode=single-process backend=filesystem` on a deployment you
+believe is multi-replica, that deployment is **not** safe to run with more than
+one task.
+
+If the server is auto-built with the Postgres backend but coordination handles
+were not wired (only possible via a manual/embedded builder), it **fails to
+start** rather than falling back to per-process state.
+
+## Behavior across replicas
+
+- **Operator & EVM login**: a challenge issued on one replica verifies on any
+ other; an established session is honored everywhere; logout and expiry are
+ effective fleet-wide.
+- **Canonicalization** runs on exactly one replica at a time via a Postgres
+ lease (`worker_leases`). Leadership transfers automatically to another replica
+ within one lease TTL (≈ 3× the canonicalization check interval) if the holder
+ crashes. Every canonicalization write (promotion, discard, retry bookkeeping)
+ validates the holder's fencing token against the lease row at its database
+ write boundary and only touches deltas still in candidate status. A write
+ that validated immediately before a leadership transfer may finish afterward;
+ account-level serialization and conditional state/candidate updates make that
+ overlap safe without globally locking the lease row. Promotion itself is
+ atomic — account state, cosigner auth sync, the candidate→canonical flip, and
+ the pending-candidate flag release commit together or not at all. A *planned*
+ stop (deploy,
+ scale-in) does not release the lease early today — there is no graceful
+ shutdown hook yet — so after replacing the lease holder, canonicalization
+ pauses for up to one TTL (~30s at the default 10s check interval) before the
+ new holder takes over. This is a stall, never a correctness issue.
+- **Rate limiting** is per-process but partitioned (see below).
+
+## Failure modes (by design)
+
+- **Shared store (Postgres) briefly unavailable → auth fails closed.** Login and
+ authenticated requests are rejected (never bypassed) until Postgres returns.
+ The canonicalization leader steps down rather than risk double-processing, and
+ resumes automatically. This is a deliberate change from the old always-
+ available in-memory behavior.
+- **DB connection budget**: each replica opens up to `GUARDIAN_DB_POOL_MAX_SIZE`
+ (default 32 in prod) connections, plus the metadata pool, plus per-request
+ session lookups. Coordination (sessions, challenges, the lease) does not add a
+ pool of its own — it shares the **metadata pool**, so per-request auth lookups
+ compete with metadata/canonicalization traffic for the same connections; size
+ `GUARDIAN_METADATA_DB_POOL_MAX_SIZE` with that in mind. With N replicas the
+ total can approach `N × (pools × size)`; keep it under Postgres
+ `max_connections`. Prod routes through RDS Proxy by default, which pools
+ server-side and absorbs much of this.
+
+## `GUARDIAN_MAX_REPLICAS` and rate limiting
+
+The configured global limits (`GUARDIAN_RATE_BURST_PER_SEC`,
+`GUARDIAN_RATE_PER_MIN`) and the dashboard per-commitment auth budgets are
+divided by `GUARDIAN_MAX_REPLICAS` so each replica enforces
+`global / GUARDIAN_MAX_REPLICAS`. With round-robin distribution the fleet
+aggregate stays at or below the configured global HTTP limit during steady-state
+operation.
+
+- Default = the deployment's **steady-state capacity** (Terraform): the
+ greater of desired count and autoscaling max when autoscaling is enabled, or
+ the desired count otherwise.
+- **Drives rate-limiting only.** It has no effect on coordination mode.
+- **Tolerance band**: when fewer than the steady-state maximum are running, the
+ fleet over-throttles. HTTP keep-alive can also pin a client to one replica,
+ throttling it at `global / capacity`. Both are accepted fail-closed outcomes.
+- **Rolling deployments**: ECS may briefly run up to
+ `server_deployment_maximum_percent` of the steady-state capacity. The
+ aggregate allowance can rise by the same factor during that window (2× with
+ the default 200%). This temporary relaxation is accepted to avoid permanent
+ steady-state over-throttling.
+- **Override** (`var.guardian_max_replicas`): an explicit value is clamped **up**
+ to steady-state capacity. Setting it higher only over-throttles.
+- **Invalid values fail fast in prod**: a set-but-unparsable or zero
+ `GUARDIAN_MAX_REPLICAS` would otherwise fall back to a divisor of 1 and
+ silently disable partitioning (fail-open). In the prod stage the server
+ refuses to start; non-prod warns and treats it as `1`.
+- **Operator login uses the same divisor**: the dashboard's per-commitment
+ challenge and verification budgets use `GUARDIAN_MAX_REPLICAS`. The default
+ prod divisor is 6, yielding 1 request/second and 5 requests/minute per
+ endpoint on a keep-alive-pinned replica. The fleet-wide budgets can be
+ overridden with
+ `GUARDIAN_DASHBOARD_COMMITMENT_RATE_BURST_PER_SEC` and
+ `GUARDIAN_DASHBOARD_COMMITMENT_RATE_PER_MIN`. Shares are clamped to **≥1 per
+ replica** so operator login can never be fully denied. The defaults (6/second
+ and 30/minute) divide exactly across the default six replicas; a custom budget
+ below the divisor can exceed its nominal fleet-wide value because of the
+ liveness clamp.
+
+## Validate the coordination behavior locally
+
+To see this contract in action before deploying — shared sessions, single-owner
+lease with failover, fail-closed auth, rate-limit partitioning — run the
+[horizontal-scaling guide](../guides/horizontal-scaling.md): two replicas
+behind a round-robin proxy sharing one Postgres, all on Docker Compose.
+
+## Filesystem backend is dev-only
+
+The filesystem backend keeps state local to one task (and does not persist audit
+events). In the prod stage the server **refuses to start** on the filesystem
+backend with an actionable error. Use it only for local development / single
+process.
diff --git a/versioned_docs/version-0.16/builder/miden-guardian/runbooks/secrets.md b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/secrets.md
new file mode 100644
index 00000000..337f686a
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/miden-guardian/runbooks/secrets.md
@@ -0,0 +1,521 @@
+---
+title: "Secrets and Key Management Runbook"
+sidebar_position: 1
+---
+Operational guide for the secrets Guardian relies on in production.
+Companion to [`docs/architecture/infra.md`](../architecture/infra.md), which
+explains *which* AWS resources hold each secret;
+this doc covers *how* to bootstrap, replace, and respond to compromise.
+
+> **Audience:** operators with AWS Secrets Manager and ECS write access for
+> the target Guardian stack.
+
+## Categories at a glance
+
+| Category | Stored in | Lifecycle | Who reads it |
+|---|---|---|---|
+| `DATABASE_URL` | Secrets Manager (`/server/database-url`) | Managed by Terraform | ECS task **execution** role, at task start |
+| RDS Proxy credentials (prod) | Secrets Manager (`/server/database-credentials`) | Managed by Terraform | RDS Proxy IAM role |
+| ACK signing keys (prod) | Secrets Manager — IDs selected by `GUARDIAN_ACK_{FALCON,ECDSA}_SECRET_ID` env vars; default `guardian-prod/server/ack-{falcon,ecdsa}-secret-key`; Terraform sets per-stack `${stack_name}/server/ack-{falcon,ecdsa}-secret-key` | Bootstrapped once via `aws-deploy.sh bootstrap-ack-keys`; never rotated by deploys; replacement is incident/migration work | ECS task **runtime** role, at server startup |
+| Storage encryption key (optional) | Secrets Manager — ID from `GUARDIAN_STORAGE_ENCRYPTION_KEY_SECRET_ID` | Created once against an empty store; rotate by adding keys to the structured secret | ECS task **runtime** role, at server startup (loaded once, cached) |
+| Operator public keys | Secrets Manager (Terraform-managed or pre-existing ARN) | Updated by editing Terraform var or rotating the secret value | ECS task runtime role, on each dashboard challenge **and each authenticated `/dashboard/*` request** (hot-reloaded — no restart needed) |
+| EVM allowed chains + RPC URLs | Secrets Manager (Terraform-managed) | Updated by editing `config/evm/chains.json` and redeploying | ECS task execution role; surfaced as env to the task |
+
+The ACK secret name is one value that travels through three places. They
+have **different variable names by design** — each layer has a distinct
+job — but they always carry the same string:
+
+| Layer | Variable | Lives where | Job |
+|---|---|---|---|
+| 1. Deploy-time | `GUARDIAN_ACK_FALCON_SECRET_NAME` / `_ECDSA_SECRET_NAME` | Your shell when running `scripts/aws-deploy.sh` | Operator-facing override. The script passes it into Terraform. |
+| 2. Terraform | `guardian_ack_falcon_secret_name` / `_ecdsa_secret_name` | [`infra/variables.tf`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/variables.tf), [`infra/data.tf:104-105`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/data.tf#L104) | Creates / looks up the Secrets Manager entry and renders the ECS task definition. |
+| 3. Runtime | `GUARDIAN_ACK_FALCON_SECRET_ID` / `_ECDSA_SECRET_ID` | The ECS task env, read by [`secrets_manager.rs:10-13`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/secrets_manager.rs#L10) | What the server actually consults at startup. |
+
+Resolution order in each layer:
+
+1. **Deploy env (`_SECRET_NAME`).** If unset, the deploy script falls
+ through to `TF_VAR_guardian_ack_*_secret_name`, then to
+ `${STACK_NAME}/server/ack-{falcon,ecdsa}-secret-key`
+ ([`aws-deploy.sh:324-329`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh#L324)).
+2. **Terraform variable.** If unset, defaults to
+ `${stack_name}/server/ack-{falcon,ecdsa}-secret-key`. Renders the ECS
+ task definition with `GUARDIAN_ACK_*_SECRET_ID` set
+ ([`infra/ecs.tf:105-110`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/ecs.tf#L105)).
+3. **Server runtime env (`_SECRET_ID`).** If unset (unusual — only
+ happens in non-Terraform prod-mode launches), falls back to the
+ code-level defaults `guardian-prod/server/ack-{falcon,ecdsa}-secret-key`.
+
+There is a deliberate drift between the code default
+(`guardian-prod/...`) and the Terraform default (`${stack_name}/...`,
+no `-prod`). In the reference AWS deploy the server always reads the
+Terraform-derived name because the ECS task definition always sets the
+`_SECRET_ID` env var — the code default only matters for hand-rolled
+prod-mode launches.
+
+## ACK signing keys
+
+ACK keys (one Falcon, one ECDSA) are Guardian's own response signers.
+Clients pin Guardian's pubkey via `GetPubkey` on first contact and verify
+every response thereafter — **stable identity matters**. Treat ACK key
+replacement as a Guardian identity change, not a routine secret rotation.
+
+For Miden multisig accounts, the Guardian commitment is also stored in
+account state. Proposal execution checks the live server commitment
+against that stored account commitment before using a new ACK signature.
+If the Secrets Manager ACK values are replaced without moving accounts to
+the new Guardian commitment, normal proposal execution for existing
+accounts will fail with a Guardian commitment mismatch. A `SwitchGuardian`
+proposal is the account-level migration path for changing that stored
+commitment.
+
+### Where the ACK secrets come from
+
+`GUARDIAN_ACK_SECRET_PROVIDER` selects the source of the ACK signing keys:
+
+| Value | Source | Default for | Identity |
+|---|---|---|---|
+| `aws` | AWS Secrets Manager (IDs from `GUARDIAN_ACK_{FALCON,ECDSA}_SECRET_ID`) | `GUARDIAN_ENV=prod` | Stable |
+| `file` | Local files (paths from `GUARDIAN_ACK_{FALCON,ECDSA}_SECRET_PATH`) | — | Stable |
+| `none` | Ephemeral keys generated in the keystore on every boot | everything else | **Changes every restart** |
+
+When unset, it falls back to the historical behavior: `aws` in prod,
+`none` otherwise. The `none` path is dev-only — a fresh identity on each
+restart freezes every account that pinned the previous commitment, with
+per-account `SwitchGuardian` as the only recovery.
+
+### Self-hosted: stable identity without AWS
+
+A self-hosted, non-AWS Guardian keeps a fixed identity with the `file`
+provider. Set `GUARDIAN_ACK_SECRET_PROVIDER=file` and point
+`GUARDIAN_ACK_FALCON_SECRET_PATH` / `GUARDIAN_ACK_ECDSA_SECRET_PATH` at files
+holding the hex-encoded secret keys
+([`file_provider.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/ack/file_provider.rs)). The file
+format is the hex string emitted by `ack-keygen` — identical to what Secrets
+Manager stores — so the same key material is portable between the two providers.
+
+Generate the pair once and write each value to its own file:
+
+```bash
+cargo run --quiet -p guardian-server --bin ack-keygen \
+ | tee /dev/stderr \
+ | { read -r json; \
+ jq -rj '.falcon_secret_key' <<<"$json" > ack-falcon-secret-key; \
+ jq -rj '.ecdsa_secret_key' <<<"$json" > ack-ecdsa-secret-key; }
+chmod 600 ack-falcon-secret-key ack-ecdsa-secret-key
+```
+
+Treat these files like any private key: keep them out of version control and
+image layers, and back them up — losing them is a Guardian identity change, with
+the same `SwitchGuardian` migration path described above. On Unix the server
+**enforces** owner-only permissions and refuses to start if the file is readable
+by group or others, so a bare file on disk must be `0600` and a Kubernetes secret
+mount needs `defaultMode: 0400` (the `0644` default is rejected).
+
+With `GUARDIAN_ACK_ECDSA_BACKEND=aws-kms` the ECDSA key comes from KMS and its
+file is never read, so you can omit `GUARDIAN_ACK_ECDSA_SECRET_PATH` entirely on
+that path; only the Falcon file is required.
+
+### Hosted ECDSA backend (AWS KMS)
+
+The ECDSA ACK signer can be backed by AWS KMS instead of a Secrets Manager
+secret, so the private key never enters the Guardian process. Set
+`GUARDIAN_ACK_ECDSA_BACKEND=aws-kms` and `GUARDIAN_ACK_ECDSA_KMS_KEY_ID` to a KMS
+key with spec `ECC_SECG_P256K1` and usage `SIGN_VERIFY`. Grant the ECS task role
+`kms:GetPublicKey` and `kms:Sign` on the key (Terraform variable
+`guardian_ack_ecdsa_kms_key_arn`). On this path the ECDSA secret in Secrets
+Manager is not used and need not exist; Falcon is unaffected.
+
+Provisioning, rotation, and deletion of the KMS key are performed with the
+provider's own tooling — Guardian only signs with an existing key. Because a KMS
+key is a distinct keypair, moving an existing deployment to KMS (or rotating the
+KMS key) changes Guardian's ECDSA identity and is a Guardian identity change, not
+a routine rotation: the same `SwitchGuardian` migration path described above
+applies to existing accounts.
+
+#### Create the key
+
+The key spec and usage are immutable after creation, and they must match
+exactly — the server fails its startup sign probe otherwise. Create the key
+once, out of band, and keep its lifecycle separate from the Terraform stack so a
+stack teardown never schedules the signing identity for deletion.
+
+The deploy script creates the key with the correct spec and an
+`alias/-ack-ecdsa` alias, refusing to overwrite an existing one, and
+prints the ARN to set:
+
+```bash
+STACK_NAME= ./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key
+```
+
+Or do it by hand:
+
+```bash
+KEY_ID=$(aws kms create-key \
+ --key-spec ECC_SECG_P256K1 \
+ --key-usage SIGN_VERIFY \
+ --description "Guardian ACK ECDSA signer" \
+ --query KeyMetadata.KeyId --output text)
+
+aws kms create-alias \
+ --alias-name alias/-ack-ecdsa \
+ --target-key-id "$KEY_ID"
+```
+
+Then pass the key ARN (or the alias ARN) to Terraform as
+`guardian_ack_ecdsa_kms_key_arn`; the deploy grants the ECS task role
+`kms:GetPublicKey` + `kms:Sign` on it and sets `GUARDIAN_ACK_ECDSA_BACKEND` /
+`GUARDIAN_ACK_ECDSA_KMS_KEY_ID` on the server. KMS does not support automatic
+rotation for asymmetric keys, which is correct here — rotating a signing
+identity is the deliberate `SwitchGuardian` migration above, never automatic. To
+retire a key, schedule deletion only after every account has migrated off the
+old commitment.
+
+#### Prod deploy: KMS ECDSA + Secrets Manager Falcon
+
+The full sequence for a prod deploy where ECDSA is KMS-backed and Falcon stays
+in Secrets Manager. Order matters: the deploy script keys off
+`TF_VAR_guardian_ack_ecdsa_kms_key_arn` (the env var, not `terraform.tfvars`) to
+decide whether to create and require the ECDSA Secrets Manager secret, so it
+must be exported **before** `bootstrap-ack-keys` and `deploy`.
+
+```bash
+export DEPLOY_STAGE=prod STACK_NAME=
+
+# 1. Create the KMS key; copy the printed ARN.
+./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key
+
+# 2. Export it so the script and Terraform both see KMS mode.
+export TF_VAR_guardian_ack_ecdsa_kms_key_arn="arn:aws:kms:...:key/"
+
+# 3. Bootstrap ACK secrets. With the ARN exported this creates only the
+# Falcon secret and skips ECDSA.
+./scripts/aws-deploy.sh bootstrap-ack-keys
+
+# 4. Deploy. Terraform grants the task role kms:Sign + kms:GetPublicKey and
+# injects GUARDIAN_ACK_ECDSA_BACKEND / GUARDIAN_ACK_ECDSA_KMS_KEY_ID.
+./scripts/aws-deploy.sh deploy
+```
+
+If the ARN is set in `terraform.tfvars` but not exported, Terraform still uses
+KMS, but `bootstrap-ack-keys` would create an unused ECDSA secret and `deploy`
+would fail validation demanding it — so export it.
+
+### Bootstrap (first prod deploy)
+
+```bash
+DEPLOY_STAGE=prod STACK_NAME= ./scripts/aws-deploy.sh bootstrap-ack-keys
+```
+
+What that command does ([`scripts/aws-deploy.sh`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh)):
+
+1. Refuses to run if a secret it would create already exists.
+2. Generates key material locally via
+ `cargo run --bin ack-keygen` (no key ever leaves the operator's host
+ except via the `aws secretsmanager create-secret` call).
+3. Creates the Falcon and ECDSA secrets in Secrets Manager with the generated
+ values. When the ECDSA signer is KMS-backed
+ (`TF_VAR_guardian_ack_ecdsa_kms_key_arn` set), it creates only the Falcon
+ secret and skips ECDSA — use `bootstrap-kms-ecdsa-key` for the KMS key.
+
+Verify. The deploy script resolves the active IDs as
+`${GUARDIAN_ACK_*_SECRET_NAME:-${TF_VAR_guardian_ack_*_secret_name:-${STACK_NAME}/server/ack-*-secret-key}}`
+([`aws-deploy.sh:324-329`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh#L324)). Mirror that
+locally:
+
+```bash
+FALCON="${GUARDIAN_ACK_FALCON_SECRET_NAME:-${TF_VAR_guardian_ack_falcon_secret_name:-${STACK_NAME:-guardian}/server/ack-falcon-secret-key}}"
+ECDSA="${GUARDIAN_ACK_ECDSA_SECRET_NAME:-${TF_VAR_guardian_ack_ecdsa_secret_name:-${STACK_NAME:-guardian}/server/ack-ecdsa-secret-key}}"
+
+aws secretsmanager describe-secret --secret-id "$FALCON"
+aws secretsmanager describe-secret --secret-id "$ECDSA"
+```
+
+Subsequent `aws-deploy.sh deploy` runs assert these secrets exist
+([`aws-deploy.sh:331`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/scripts/aws-deploy.sh#L331)) and fail fast
+otherwise.
+
+### Replacement
+
+ACK replacement is **not** part of the regular deploy cycle and should not
+be scheduled as a routine annual rotation. Use it when no accounts are
+bound to the old Guardian commitment, when standing up a replacement
+Guardian, or as part of incident response after suspected key exposure.
+
+Before replacing ACK values for a live stack, decide how existing accounts
+will be migrated:
+
+- Prefer a `SwitchGuardian` flow that moves each account to a Guardian
+ endpoint whose `GetPubkey` already returns the new commitment.
+- For emergency compromise response, replacing the secret immediately
+ stops the old identity from signing new ACKs, but existing accounts must
+ still be moved to the new commitment before normal non-switch proposal
+ execution resumes.
+- Downstream clients that cache or pin Guardian identity must refetch
+ `GetPubkey` after the change.
+
+Procedure:
+
+1. Generate new key material on a trusted host:
+ ```bash
+ cargo run --quiet --package guardian-server --bin ack-keygen > /tmp/ack-keys.json
+ ```
+2. Put new values into Secrets Manager — `update-secret` creates a new
+ version without disturbing the secret ID. Reuse the same
+ `$FALCON` / `$ECDSA` IDs you resolved in the Verify block above so
+ multi-stack deploys hit the right secret:
+ ```bash
+ FALCON_VALUE=$(jq -r .falcon_secret_key /tmp/ack-keys.json)
+ ECDSA_VALUE=$(jq -r .ecdsa_secret_key /tmp/ack-keys.json)
+
+ aws secretsmanager update-secret \
+ --secret-id "$FALCON" \
+ --secret-string "$FALCON_VALUE"
+ aws secretsmanager update-secret \
+ --secret-id "$ECDSA" \
+ --secret-string "$ECDSA_VALUE"
+ ```
+3. Force a new ECS deployment so tasks restart and import the new keys:
+ ```bash
+ aws ecs update-service --cluster -cluster \
+ --service -server --force-new-deployment
+ ```
+4. Confirm the replacement:
+ ```bash
+ curl https://guardian.openzeppelin.com/pubkey
+ ```
+ Should return the new key material.
+5. Securely shred `/tmp/ack-keys.json`.
+
+### Compromise response
+
+If you believe an ACK secret leaked:
+
+1. **Immediately** replace the ACK values using the procedure above —
+ bypass any change window.
+2. Revoke any operator AWS credentials that could have read the secret
+ (CloudTrail `GetSecretValue` events scoped to those secret ARNs are the
+ audit trail).
+3. Force-cycle all tasks (`update-service --force-new-deployment`) so the
+ old keys are no longer resident in any task's filesystem keystore.
+4. Move affected accounts to the new Guardian commitment with the
+ account-level `SwitchGuardian` flow, or keep them paused/unavailable
+ until that migration is complete.
+5. Inform downstream clients to refetch the pubkey and invalidate cached
+ verifiers.
+6. File an incident referencing the secret ARN, the replacement timestamp,
+ and the CloudTrail evidence.
+
+## Storage encryption key
+
+Optional. Encrypts account state and delta/proposal payloads at rest (see
+[`PRODUCTION.md`](../operations/production.md#storage-encryption)). The key never leaves the
+process boundary beyond Secrets Manager; it is loaded once at startup and cached.
+
+### Bootstrap (against an empty store)
+
+On the standard AWS stack, use the deploy script. It generates the key locally,
+creates the structured secret (`{active, keys}`), and refuses to overwrite an
+existing one:
+
+```bash
+DEPLOY_STAGE=prod STACK_NAME=guardian-prod \
+ ./scripts/aws-deploy.sh bootstrap-storage-encryption-key
+```
+
+Then enable it on the next deploy by exporting the secret name (the bootstrap
+command prints it). Setting `GUARDIAN_STORAGE_ENCRYPTION_SECRET_NAME` is what turns
+encryption on: Terraform injects `GUARDIAN_STORAGE_ENCRYPTION_KEY_SECRET_ID` and
+grants the ECS **runtime** role `secretsmanager:GetSecretValue` on the secret:
+
+```bash
+GUARDIAN_STORAGE_ENCRYPTION_SECRET_NAME=guardian-prod/server/storage-encryption-key \
+ DEPLOY_STAGE=prod STACK_NAME=guardian-prod ./scripts/aws-deploy.sh deploy
+```
+
+The bootstrap command defaults the name to `/server/storage-encryption-key`;
+leaving `GUARDIAN_STORAGE_ENCRYPTION_SECRET_NAME` unset on deploy keeps storage in
+plaintext. The equivalent manual creation writes the document to a restricted
+temp file and passes `file://` so the key never lands in the process arg list
+(`ps` / `/proc`):
+
+```bash
+f=$(mktemp)
+jq -nc --arg k "$(openssl rand -base64 32)" '{active:"k1", keys:{k1:$k}}' >"$f"
+aws secretsmanager create-secret \
+ --name guardian-prod/server/storage-encryption-key \
+ --secret-string "file://$f"
+rm -f "$f"
+```
+
+The server writes a one-time encryption marker on the first write; it will refuse
+to start if the key is configured against a store that already holds plaintext
+records.
+
+### Rotation
+
+Add a new key and repoint `active`, keeping the previous key so existing records
+still decrypt:
+
+```bash
+f=$(mktemp)
+jq -nc --arg k1 "$OLD_B64" --arg k2 "$(openssl rand -base64 32)" \
+ '{active:"k2", keys:{k1:$k1, k2:$k2}}' >"$f"
+aws secretsmanager put-secret-value \
+ --secret-id guardian-prod/server/storage-encryption-key \
+ --secret-string "file://$f"
+rm -f "$f"
+```
+
+New records use `k2`; old `k1` records keep decrypting. Do **not** remove a key
+that any stored record still references. Bulk re-encryption tooling is not yet
+provided.
+
+### Nonce budget
+
+Records are sealed with AES-256-GCM under a random 96-bit nonce. Per NIST
+SP 800-38D, a single key must encrypt fewer than ~2³² records (about 4 billion)
+before nonce-collision probability becomes non-negligible — and a collision
+under one key is catastrophic, not graceful.
+
+Each state, delta, and proposal write is one encryption under the **active**
+key. Treat 2³² as a per-key budget: [rotate](#rotation) the active key well
+before any single `active` kid reaches ~1 billion writes (2³⁰, a ~4× margin),
+or on a calendar cadence sized to the deployment's write rate. Rotation only
+repoints `active` — old records keep decrypting under their original kid — so
+the budget resets with each rotation. If 2³² is unreachable for the
+deployment's lifetime, no cadence is required.
+
+### Compromise response
+
+Treat as a confidentiality breach of account state/history (not key material —
+Guardian is non-custodial). Rotate the key, and because old records remain
+readable with the old key, plan a re-encryption migration before retiring it.
+
+Note that the key the store was first initialized with (`init_kid`, recorded in
+the store's encryption marker) must stay resolvable in the structured secret for
+the server to start, **even after every record has been re-encrypted to a newer
+kid**. Fully retiring the initial key therefore requires rewriting the store
+marker, not just dropping the key from the secret — and there is no operator
+command for that yet. If the initial key is itself the compromised one, track
+the marker rewrite as migration work rather than assuming a key drop suffices.
+
+## Operator public keys
+
+Operators authenticate to the dashboard via Falcon-signed challenges
+against an **allowlist** of public keys. Two ways to manage the list:
+
+- **Terraform-managed** — set `guardian_operator_public_keys` in
+ Terraform (or `GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON` in the deploy env);
+ Terraform creates and maintains the secret. The variable is typed
+ `list(string)`, so this path only supports the legacy bare-key
+ array form — every entry implicitly gets `dashboard:read` only.
+- **Pre-existing ARN** — set
+ `guardian_operator_public_keys_secret_arn` to an ARN you manage
+ externally. Terraform won't touch the contents. Use this path (or
+ the local `GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE`) when you need the
+ object form to grant `accounts:pause` or any future permission.
+
+The secret payload is the JSON shape consumed by
+[`dashboard/allowlist.rs`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/allowlist.rs).
+See [`docs/DASHBOARD.md`](../operations/dashboard.md#enrolling-an-operator) for the
+payload schema and an enrollment walkthrough.
+
+### Adding or removing an operator
+
+The server **rereads the operator secret on every dashboard challenge and
+every authenticated `/dashboard/*` request** — no ECS restart needed for
+allowlist changes.
+
+Terraform-managed path:
+1. Edit `guardian_operator_public_keys` (or the env var the deploy script
+ reads from).
+2. `./scripts/aws-deploy.sh deploy` — Terraform updates the secret in
+ place. Effect is immediate.
+
+Externally-managed path:
+1. Update the secret with `aws secretsmanager update-secret`. That's it.
+
+### Revoking a compromised operator
+
+The hot-reload path makes this fast — no deploy window required:
+
+```bash
+aws secretsmanager update-secret \
+ --secret-id \
+ --secret-string "$(cat new-operator-list.json)"
+```
+
+The next challenge issuance or authenticated request from any task picks
+up the new list and rejects the revoked key. Active sessions belonging
+to the revoked operator are rejected at their next call: the per-request
+reload re-validates the operator against the current allowlist on every
+authenticated `/dashboard/*` hit
+([`dashboard/state.rs:284-324`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/crates/server/src/dashboard/state.rs#L284)).
+
+Operator sessions are in-memory per task. There is no ALB session
+stickiness, so on multi-task deployments operators may be routed to a
+task that did not mint their session and prompted to re-authenticate
+— this is the normal failure mode, not a revocation signal. Use the
+audit / CloudTrail trail (below) to confirm a revocation took effect.
+
+## `DATABASE_URL` and RDS Proxy credentials
+
+Both are **created and owned by Terraform** ([`infra/rds.tf:45`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L45),
+[`infra/rds.tf:50`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/infra/rds.tf#L50)). Do not edit them by hand —
+the next `terraform apply` will overwrite your change.
+
+To rotate the database password:
+1. Set `postgres_password` to a new value in `terraform.tfvars` (or unset
+ it to let Terraform regenerate via `random_password`).
+2. `terraform apply` — Terraform updates the RDS master password,
+ `DATABASE_URL`, and the proxy credentials secret atomically.
+3. ECS rolls the service automatically on the next deploy; force it
+ sooner with `update-service --force-new-deployment`.
+
+There is no separate read-only credential; the server connects with the
+master user. This is a known production-hardening gap.
+
+## EVM allowed chains and RPC URLs
+
+Populated by the deploy script from
+[`config/evm/chains.json`](https://github.com/OpenZeppelin/guardian/blob/v0.17.0/config/evm/chains.json) when
+`GUARDIAN_SERVER_FEATURES=postgres,evm`.
+
+To add a chain:
+1. Edit `config/evm/chains.json` — append a new entry to `chains` with
+ `chainId`, `name`, `network`, and `rpcUrl`. The `entrypointAddress`
+ is a single top-level field shared by every chain (exposed to the
+ server as `GUARDIAN_EVM_ENTRYPOINT_ADDRESS`) — do not add it
+ per-chain.
+2. `./scripts/aws-deploy.sh deploy` — the script rebuilds the Secrets
+ Manager values from the JSON and Terraform updates the secret
+ versions.
+3. ECS rolls and the new task reads the updated lists.
+
+To rotate an RPC URL (e.g. switch provider):
+1. Edit `chains.json`, redeploy. No special handling — the server treats
+ chain config as a startup-time read.
+
+## Audit trail
+
+CloudTrail `GetSecretValue` events are scoped per-secret ARN. The
+relevant principals you should see hitting each secret:
+
+| Secret | Expected principals |
+|---|---|
+| `DATABASE_URL` | ECS task execution role only |
+| `database-credentials` (proxy) | RDS Proxy IAM role only |
+| ACK Falcon / ECDSA | ECS task runtime role (on cold start) + operators running `bootstrap-ack-keys` or emergency replacement |
+| Operator pubkeys | ECS task runtime role + operators updating the list |
+| EVM chains / RPCs | ECS task execution role only |
+
+Any other principal touching these secrets is suspicious.
+
+## What is deliberately not here
+
+- **No KMS CMK** — Secrets Manager uses the default AWS-owned key. Move
+ to a CMK before enabling cross-account access.
+- **No automated rotation lambdas** — secret changes are operator-driven.
+- **No envelope encryption of ACK secret values** — Secrets Manager
+ protects the secret at rest; the value itself is the raw key material
+ that the server imports into its filesystem keystore on startup.
diff --git a/versioned_docs/version-0.16/builder/migration/01-imports-dependencies.md b/versioned_docs/version-0.16/builder/migration/01-imports-dependencies.md
new file mode 100644
index 00000000..3ed263a3
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/01-imports-dependencies.md
@@ -0,0 +1,198 @@
+---
+sidebar_position: 1
+title: "Imports & Dependencies"
+description: "Crate version bumps, the VM 0.23 to 0.29 jump, MSRV 1.96, and the artifacts that must be regenerated for v0.16"
+---
+
+# Imports & Dependencies
+
+:::warning Breaking Change
+The protocol crates move from 0.15.3 to **0.16.0**, `miden-client` from 0.15 to **0.16.0**, and the VM crates jump **0.23 → 0.29.1** — six minor versions, not one. `miden-crypto` was absorbed into the Miden VM workspace and now shares its version number (0.25 → **0.29.1**). The MSRV is Rust **1.96**. Because the MAST wire format, the package format, and several commitment preimages changed, **0.15 artifacts do not round-trip**: re-assemble every package from source, recreate your local store, and upgrade your node in lockstep with your client.
+:::
+
+## Quick Fix
+
+```toml title="Cargo.toml"
+# Replace these
+miden-client = "0.15"
+miden-client-sqlite-store = "0.15"
+miden-protocol = "0.15.3"
+miden-standards = "0.15.3"
+miden-tx = "0.15.3"
+miden-tx-batch-prover = "0.15.3"
+miden-assembly = "0.23"
+miden-core = "0.23"
+miden-core-lib = "0.23"
+miden-processor = "0.23"
+miden-prover = "0.23"
+miden-crypto = "0.25"
+
+# With these
+miden-client = "0.16.0-rc.1"
+miden-client-sqlite-store = "0.16.0-rc.1"
+miden-protocol = "0.16.0-rc.6"
+miden-standards = "0.16.0-rc.6"
+miden-tx = "0.16.0-rc.6"
+miden-tx-batch = "0.16.0-rc.6" # renamed from miden-tx-batch-prover
+miden-assembly = "0.29.1"
+miden-core = "0.29.1"
+miden-core-lib = "0.29.1"
+miden-processor = "0.29.1"
+miden-prover = "0.29.1"
+miden-crypto = "0.29.1"
+```
+
+```json title="package.json (Web SDK)"
+{
+ "@miden-sdk/miden-sdk": "0.16.0-rc.2",
+ "@miden-sdk/react": "0.16.0-rc.2"
+}
+```
+
+Then run:
+
+```bash
+cargo update && cargo build
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+:::note Pin the exact version
+The 0.16 protocol and client crates currently publish as `0.16.0-rc.N` pre-releases. Cargo does **not** match a pre-release against a plain requirement, so `miden-protocol = "0.16"` will fail to resolve. Pin the exact string as shown above until the final release is published.
+:::
+
+:::warning 0.15 artifacts do not round-trip
+The MAST wire format moved `0.0.3` → `0.0.4` and the package format `4.0.0` → `6.0.0`, so **serialized packages and `MastForest` blobs from 0.15 will not load**. The `.masl` library format no longer exists at all. Several commitment preimages also changed (ECDSA public keys, MMR peaks, empty domain-separated hashes), so derived values must be recomputed. Re-assemble from source and re-sync into a fresh store.
+:::
+
+---
+
+## Summary
+
+Every layer of the stack moves:
+
+- The **protocol crates** (`miden-protocol`, `miden-standards`, `miden-tx`, `miden-testing`) go `0.15.3` → `0.16.0`.
+- The **VM crates** (`miden-assembly`, `miden-core`, `miden-core-lib`, `miden-processor`, `miden-prover`, `miden-mast-package`) go `0.23` → `0.29.1`. This is a much larger jump than previous releases and carries breaking MASM language changes — see [VM & Assembler Changes](./vm-assembler).
+- **`miden-crypto`** goes `0.25` → `0.29.1`. It is no longer an independent crate line: it was imported into the Miden VM workspace and now shares the VM version number.
+- **`miden-client`** and `miden-client-sqlite-store` go `0.15` → `0.16.0`.
+- The **Web SDK** packages go `0.15` → `0.16.0`.
+
+Two crates changed identity: `miden-tx-batch-prover` is now **`miden-tx-batch`**, and a new **`miden-protocol-build-utils`** crate provides MASM assembly helpers. On the VM side the core package was split, adding a **`miden-precompiles`** package alongside `miden-core`.
+
+---
+
+## Version Bumps
+
+| Crate | v0.15 | v0.16 |
+|-------|-------|-------|
+| `miden-client` | 0.15 | 0.16.0 |
+| `miden-client-sqlite-store` | 0.15 | 0.16.0 |
+| `miden-protocol` | 0.15.3 | 0.16.0 |
+| `miden-standards` | 0.15.3 | 0.16.0 |
+| `miden-tx` | 0.15.3 | 0.16.0 |
+| `miden-testing` | 0.15.3 | 0.16.0 |
+| `miden-tx-batch-prover` | 0.15.3 | **renamed** to `miden-tx-batch` 0.16.0 |
+| `miden-protocol-build-utils` | — | 0.16.0 *(new)* |
+| `miden-assembly` | 0.23 | 0.29.1 |
+| `miden-core` | 0.23 | 0.29.1 |
+| `miden-core-lib` | 0.23 | 0.29.1 |
+| `miden-processor` | 0.23 | 0.29.1 |
+| `miden-prover` | 0.23 | 0.29.1 |
+| `miden-verifier` | 0.23 | 0.29.1 |
+| `miden-mast-package` | 0.23 | 0.29.1 |
+| `miden-precompiles` | — | 0.29.1 *(new)* |
+| `miden-crypto` | 0.25 | 0.29.1 |
+
+| npm package | v0.15 | v0.16 |
+|-------------|-------|-------|
+| `@miden-sdk/miden-sdk` | 0.15.x | 0.16.0 |
+| `@miden-sdk/react` | 0.15.x | 0.16.0 |
+| `@miden-sdk/vite-plugin` | — | 0.16.0 |
+
+:::note `miden-idxdb-store` is not a package
+Earlier guidance listed a `miden-idxdb-store` npm dependency. No such package exists on the public registry — the IndexedDB store ships inside `@miden-sdk/miden-sdk`. Remove it from your `package.json` if you carried it over.
+:::
+
+---
+
+## Affected Code
+
+**Cargo.toml:**
+```diff
+- miden-client = "0.15"
+- miden-client-sqlite-store = "0.15"
+- miden-protocol = "0.15.3"
+- miden-standards = "0.15.3"
+- miden-tx = "0.15.3"
+- miden-tx-batch-prover = "0.15.3"
+- miden-assembly = "0.23"
+- miden-core = "0.23"
+- miden-core-lib = "0.23"
+- miden-processor = "0.23"
+- miden-prover = "0.23"
+- miden-crypto = "0.25"
++ miden-client = "0.16.0-rc.1"
++ miden-client-sqlite-store = "0.16.0-rc.1"
++ miden-protocol = "0.16.0-rc.6"
++ miden-standards = "0.16.0-rc.6"
++ miden-tx = "0.16.0-rc.6"
++ miden-tx-batch = "0.16.0-rc.6"
++ miden-assembly = "0.29.1"
++ miden-core = "0.29.1"
++ miden-core-lib = "0.29.1"
++ miden-processor = "0.29.1"
++ miden-prover = "0.29.1"
++ miden-crypto = "0.29.1"
+```
+
+**package.json (Web SDK):**
+```diff
+- "@miden-sdk/miden-sdk": "^0.15.0",
+- "@miden-sdk/react": "^0.15.0",
+- "miden-idxdb-store": "^0.15.0"
++ "@miden-sdk/miden-sdk": "0.16.0-rc.2",
++ "@miden-sdk/react": "0.16.0-rc.2"
+```
+
+---
+
+## MSRV (Minimum Supported Rust Version)
+
+The MSRV rose across the board. Update your `rust-toolchain.toml` to Rust **1.96**:
+
+```toml title="rust-toolchain.toml"
+[toolchain]
+channel = "1.96"
+```
+
+| Component | v0.15 | v0.16 |
+|-----------|-------|-------|
+| protocol crates | 1.90 | 1.96.1 |
+| `miden-client` | 1.93 | 1.96 |
+| Miden VM | 1.90 | 1.96 |
+
+---
+
+## Migration Steps
+
+1. Bump every Miden crate per the table above, pinning the exact `0.16.0-rc.N` strings for the protocol and client crates, and run `cargo update`.
+2. Rename the `miden-tx-batch-prover` dependency to **`miden-tx-batch`** if you used it.
+3. Set your toolchain to at least Rust `1.96`.
+4. Bump `@miden-sdk/miden-sdk` and `@miden-sdk/react` together — mixing 0.15 and 0.16 packages will not link against the shared WASM ABI. Drop any `miden-idxdb-store` dependency.
+5. Re-assemble every `.masp` package from source under the new toolchain, and delete cached `MastForest` blobs. The `.masl` format is gone entirely.
+6. **Recreate your local store.** The SQLite store's schema fingerprint changed and existing databases are rejected; browser users have their IndexedDB store cleared automatically on the version bump. See [Client Changes](./client-changes).
+7. **Upgrade your node together with your client.** 0.16 clients seal transaction inputs before submission; a 0.16 node rejects plaintext submissions and an older node rejects sealed ones, so the two cannot be mixed.
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `failed to select a version for miden-protocol` | A plain `"0.16"` requirement will not match a `0.16.0-rc.N` pre-release | Pin the exact version string, e.g. `"0.16.0-rc.6"`. |
+| `failed to select a version for miden-tx-batch-prover` | Crate renamed in 0.16 | Depend on `miden-tx-batch` instead. |
+| `MastForest deserialization failed: unexpected version` | MAST wire format moved to `0.0.4` | Re-assemble every package from source under VM 0.29.1. |
+| package fails to load with a version mismatch | Package format moved to `6.0.0` | Rebuild the `.masp`; `.masl` is no longer supported at all. |
+| `Migration error: Attempt to migrate a database with a migration number that is too high` | Existing SQLite store predates the 0.16 schema | Delete and recreate the store, then re-sync. |
+| Node rejects a submitted transaction | Client and node versions are mixed | Upgrade both to 0.16; sealed and plaintext submissions are mutually incompatible. |
+| `rustc` version error during build | MSRV raised to 1.96 | Update `rust-toolchain.toml`. |
diff --git a/versioned_docs/version-0.16/builder/migration/02-hashing-crypto.md b/versioned_docs/version-0.16/builder/migration/02-hashing-crypto.md
new file mode 100644
index 00000000..7b3c9330
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/02-hashing-crypto.md
@@ -0,0 +1,135 @@
+---
+sidebar_position: 2
+title: "Hashing & Crypto Changes"
+description: "Commitment preimages that changed in v0.16 — ECDSA public keys, MMR peaks, and domain-separated empty inputs"
+---
+
+# Hashing & Crypto Changes
+
+:::warning Breaking Change
+Four commitment preimages changed. Any value you have **persisted** — in account storage, note storage, an advice map key, or your own database — that was derived from an ECDSA public key, an MMR peak set, a domain-separated empty input, or `hash_bytes(&[])` is now wrong and must be recomputed. These changes are silent: nothing fails to compile, and the old values simply no longer match.
+:::
+
+## Quick Fix
+
+```rust
+// Recompute every stored ECDSA public-key commitment
+use miden_crypto::dsa::ecdsa_k256_keccak::PublicKey;
+let commitment: Word = public_key.to_commitment();
+```
+
+Then re-derive anything downstream: account storage slots, note storage, and advice-map keys built from those commitments.
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+Unlike most of this release, nothing here breaks your build. These are value changes, so the symptom is a proof that fails to verify, an account whose storage no longer matches, or an advice-map lookup that misses — all at runtime, all without a compiler error pointing at the cause.
+
+The rule of thumb: if you stored a hash, recompute it. If you only ever compute hashes on the fly from current inputs, you are unaffected.
+
+---
+
+## ECDSA k256 public-key commitment format changed
+
+### Summary
+
+The ECDSA-k256/Keccak public-key commitment now hashes the **native affine coordinate limbs** (`qx || qy` as little-endian `u32` limbs) instead of the compressed SEC1 public-key bytes. Compressed SEC1 *serialization* of the key itself is unchanged — only the commitment value changed ([#3342](https://github.com/0xMiden/miden-vm/pull/3342), [crypto#1075](https://github.com/0xMiden/crypto/issues/1075)).
+
+### Affected Code
+
+```text
+v0.15: PK_COMM = Poseidon2::hash_elements( 33 compressed SEC1 bytes packed as 9 felts )
+v0.16: PK_COMM = Poseidon2::hash_elements( QX[8] || QY[8] ) # native LE u32 limbs
+```
+
+```rust
+// After (0.16) — regenerate every stored commitment
+use miden_crypto::dsa::ecdsa_k256_keccak::PublicKey;
+let commitment: Word = public_key.to_commitment();
+```
+
+### Migration Steps
+
+1. Recompute every stored ECDSA public-key commitment with `PublicKey::to_commitment()`.
+2. Re-derive anything downstream of that commitment — account storage slots, note storage, advice-map keys.
+3. No MASM call-site changes are needed. The operand-stack contract of `ecdsa_k256_keccak::verify` is still `[PK_COMM, MSG_WORD, ...]`; only the value of `PK_COMM` moved. The **advice** layout did change, though — see [MASM Changes](./masm-changes#ecdsa-advice-and-signature-abi-changed).
+
+---
+
+## MMR peak commitments now bind the leaf count
+
+### Summary
+
+MMR peak commitments are computed over `[num_leaves, 0, 0, 0] || padded_peaks` instead of `padded_peaks` alone, on both the Rust and MASM sides. **All MMR peak commitments change** ([#3388](https://github.com/0xMiden/miden-vm/pull/3388)).
+
+### Affected Code
+
+```text
+v0.15: hash_peaks() = Poseidon2::hash_elements( padded_peaks )
+v0.16: hash_peaks() = Poseidon2::hash_elements( [num_leaves, 0, 0, 0] || padded_peaks )
+```
+
+The `miden::core::collections::mmr` `pack` and `unpack` procedures were updated to the same preimage. In 0.15, `pack` hashed the range starting at `mmr_ptr + 4`, skipping the leaf-count word; in 0.16 it hashes from `mmr_ptr`, so the leaf count is absorbed first. The MASM stack contracts (`[mmr_ptr, ...] -> [HASH, ...]`) are unchanged.
+
+### Migration Steps
+
+1. Recompute and re-persist every stored MMR peak commitment.
+2. Invalidate any cached chain-MMR commitment, advice-map entry keyed by an MMR commitment, or proof whose witness depends on one.
+3. No MASM call-site changes — `mmr::pack` and `mmr::unpack` keep their signatures.
+
+---
+
+## Domain-separated empty-input hashing changed
+
+### Summary
+
+`hash_elements_in_domain(&[], d)` for a nonzero domain `d` used to collide with other inputs. The fix marks the empty-input case in the third capacity element and applies a permutation, so the result is now a distinct, nonzero digest ([#3447](https://github.com/0xMiden/miden-vm/pull/3447), refining [#3366](https://github.com/0xMiden/miden-vm/pull/3366)).
+
+Related and also digest-changing: `hash_bytes(&[])` no longer returns `Word::default()`. The empty-bytes input now absorbs a padding marker and permutes, producing a nonzero digest consistent with the `10*` sponge padding rule ([#3366](https://github.com/0xMiden/miden-vm/pull/3366)).
+
+### Affected Code
+
+```rust
+// After (0.16) — the empty-input branch, from the algebraic sponge implementation
+} else if total_len == 0 && state[CAPACITY_RANGE.start + 1] != ZERO {
+ // Mark an empty domain-separated input in an otherwise unused capacity element.
+ state[CAPACITY_RANGE.start + 2] = Felt::ONE;
+ S::apply_permutation(&mut state);
+}
+```
+
+### Migration Steps
+
+1. Re-derive any commitment computed as `hash_elements_in_domain` over an empty element list with a nonzero domain — typically "empty collection" sentinel values.
+2. Re-derive any value computed as `hash_bytes(&[])`. A stored zero word is no longer the right answer.
+3. `merge_in_domain` and non-empty `hash_elements_in_domain` inputs are unaffected.
+
+---
+
+## `AeadPoseidon2` key derivation restored to canonical decoding
+
+### Summary
+
+`AeadPoseidon2::key_from_bytes` was restored to canonical-`Felt` decoding ([#3366](https://github.com/0xMiden/miden-vm/pull/3366)). Keys persisted under the brief SHA-256 KDF contract must be re-derived.
+
+### Migration Steps
+
+1. If you persisted AEAD keys derived with `key_from_bytes` during the 0.16 pre-release window, re-derive them.
+2. Data encrypted under a key derived by the interim contract cannot be decrypted with a canonically-derived key — re-encrypt it.
+
+---
+
+## Common Errors
+
+These changes do not produce compile errors. Expect runtime symptoms instead:
+
+| Symptom | Cause | Solution |
+| --- | --- | --- |
+| Signature verification traps for a key that worked in 0.15 | Stored `PK_COMM` uses the old preimage | Recompute with `PublicKey::to_commitment()`. |
+| Advice-map lookup misses for a key you know you inserted | The key is a changed commitment | Re-derive the key. |
+| Chain-MMR commitment mismatch after upgrading | Peak commitment now binds the leaf count | Recompute and re-persist. |
+| An "empty" sentinel commitment no longer matches | Empty-input hashing changed | Re-derive the sentinel. |
+| Previously encrypted data fails to decrypt | AEAD key derivation changed | Re-derive the key and re-encrypt. |
diff --git a/versioned_docs/version-0.16/builder/migration/03-account-changes.md b/versioned_docs/version-0.16/builder/migration/03-account-changes.md
new file mode 100644
index 00000000..1c46191e
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/03-account-changes.md
@@ -0,0 +1,325 @@
+---
+sidebar_position: 3
+title: "Account Changes"
+description: "Auth components become ordinary components, Approver replaces raw key arguments, component names change, and account updates move to AccountPatch"
+---
+
+# Account Changes
+
+:::warning Breaking Change
+`AccountBuilder::with_auth_component` was removed — auth components are now passed through `with_component` like any other, and identified by their MASM `@auth_script` attribute. Auth components take an `Approver` instead of a raw key and scheme. Every standard component's `NAME` constant changed, and because component metadata feeds the storage schema commitment, **accounts rebuilt from the same seed will have different commitments**.
+:::
+
+## Quick Fix
+
+```rust
+// Before (0.15)
+let account = AccountBuilder::new(init_seed)
+ .account_type(AccountType::Public)
+ .with_auth_component(AuthSingleSig::new(pub_key, auth_scheme))
+ .with_component(BasicWallet)
+ .build()?;
+
+// After (0.16)
+let account = AccountBuilder::new(init_seed)
+ .account_type(AccountType::Public)
+ .with_component(AuthSingleSig::new(Approver::new(pub_key, auth_scheme)))
+ .with_component(BasicWallet)
+ .build()?;
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+Three independent shifts land on accounts in this release.
+
+**Auth stops being special.** In 0.15 the builder had a dedicated auth slot; in 0.16 the auth component is just a component, and the builder finds it by looking for the `@auth_script` attribute in its MASM. This is what makes the fee change possible — the auth procedure is now also where fees get paid, so it needed to compose with everything else.
+
+**Keys are wrapped in an `Approver`.** `AuthSingleSig::new` took a public-key commitment and a scheme; it now takes a single `Approver` carrying both. Multi-signature components take an `ApproverSet` with a threshold. This is a mechanical rewrite, but it touches every account you construct.
+
+**Component names were normalised**, which changes commitments. Every standard component's `NAME` dropped its `components::` segment. Since the name feeds component metadata, and metadata feeds the storage schema commitment, this silently changes account commitments even when nothing else about your account changed.
+
+---
+
+## `AccountBuilder::with_auth_component` removed
+
+### Summary
+
+`AccountBuilder` now takes all components uniformly through `with_component`, and identifies the auth component by its `@auth_script` MASM attribute.
+
+### Affected Code
+
+```diff
+ let account = AccountBuilder::new(init_seed)
+ .account_type(AccountType::Public)
+- .with_auth_component(auth_component)
++ .with_component(auth_component)
+ .with_component(BasicWallet)
+ .build()?;
+```
+
+Reach for `with_component` whenever you are naming a component yourself — it takes `impl Into`, so no explicit `.into()` is needed. `with_components` is for the case where the count is not known at the call site: a configuration value that expands into one or several components depending on its variant. `AuthNetworkAccount` is the example in this release — see [Network accounts require a fee policy](#network-accounts-require-a-fee-policy).
+
+`AccountBuilder` also gained `with_asset_callbacks(AssetCallbackFlag)`. Whether a faucet's assets trigger callbacks is now encoded in the account ID rather than in separate storage, so this is set at construction time.
+
+### Migration Steps
+
+1. Drop `with_auth_component` and pass the auth component through `with_component`, like any other.
+2. If you author a custom auth component, make sure its entry procedure carries the `@auth_script` attribute — that is how the builder recognises it.
+3. If you build a faucet whose assets should trigger callbacks, set `with_asset_callbacks`.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `no method named with_auth_component` | Method removed | Use `with_component`. |
+| Build fails reporting no auth component | Custom component lacks the attribute | Annotate the entry procedure with `@auth_script`. |
+
+---
+
+## `Approver` and `ApproverSet` replace raw key arguments
+
+### Summary
+
+`Approver` bundles a public-key commitment with its signature scheme; `ApproverSet` bundles a list of approvers with a threshold. Both are new in 0.16. The `AuthMethod` enum was removed, and `AuthSingleSigAcl` / `AuthSingleSigAclConfig` were removed outright.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+AuthSingleSig::new(pub_key: PublicKeyCommitment, auth_scheme: AuthScheme) -> Self
+```
+
+```rust
+// After (0.16)
+Approver::new(pub_key: PublicKeyCommitment, auth_scheme: AuthScheme) -> Approver
+AuthSingleSig::new(approver: Approver) -> Self
+ApproverSet::new(approvers: Vec, threshold: u32) -> Result
+AuthMultisig::new(approver_set: ApproverSet) -> Self
+```
+
+The convenience constructors are unchanged and remain the shortest path when you have a concrete key:
+
+```rust
+// Identical in 0.15 and 0.16
+AuthSingleSig::falcon512_poseidon2(pub_key)
+AuthSingleSig::ecdsa_k256_keccak(pub_key)
+AuthSingleSig::from_public_key(pub_key)
+```
+
+New accessors: `AuthSingleSig::approver()`, `ApproverSet::approvers()`, and `ApproverSet::threshold()`.
+
+### Migration Steps
+
+1. Wrap existing `AuthSingleSig::new(pub_key, scheme)` arguments in `Approver::new(pub_key, scheme)`.
+2. Replace multi-signature construction with `ApproverSet::new(approvers, threshold)?` — note it is fallible.
+3. Remove any use of `AuthMethod`; the scheme now travels inside the `Approver`.
+4. If you used `AuthSingleSigAcl`, there is no drop-in replacement. Rebuild the access-control policy using the components under `miden::standards::access` (for example `RoleBasedAccessControl` or `Authority`).
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `this function takes 1 argument but 2 were supplied` on `AuthSingleSig::new` | Signature changed | Wrap the arguments in `Approver::new`. |
+| `cannot find type AuthMethod` | Removed | Use `Approver` / `AuthScheme`. |
+| `cannot find type AuthSingleSigAcl` | Removed | Rebuild with an access-control component. |
+
+---
+
+## Component names changed, and account commitments with them
+
+### Summary
+
+Every standard component's `NAME` constant was normalised by dropping the `components::` segment. Component metadata feeds the storage schema commitment, so **the commitment of an account built from the same seed and the same components differs between 0.15 and 0.16**.
+
+### Affected Code
+
+```diff
+- "miden::standards::components::auth::singlesig"
++ "miden::standards::auth::singlesig"
+
+- "miden::standards::components::wallets::basic_wallet"
++ "miden::standards::wallets::basic_wallet"
+
+- "miden::standards::components::access::rbac"
++ "miden::standards::access::rbac"
+
+- "miden::standards::components::faucets::fungible_faucet"
++ "miden::standards::faucets::fungible"
+```
+
+A few names already lacked the segment in 0.15 — `miden::standards::auth::network_account` and `miden::standards::access::ownable2step` are unchanged. Note that the fungible faucet also lost its `_faucet` suffix, so it is not a pure prefix change.
+
+The `miden::standards::account::metadata` module was also renamed to `miden::standards::account::inspection`, both in MASM and in Rust.
+
+### Migration Steps
+
+1. Update any hard-coded component name strings.
+2. Expect new account IDs and commitments for accounts rebuilt from the same seed. If you have persisted an account ID derived under 0.15, it will not be reproduced by 0.16 construction.
+3. Update references to `account::metadata` to `account::inspection`.
+
+---
+
+## Component MASM must annotate exported procedures
+
+### Summary
+
+Account component MASM must annotate every exported procedure with `@account_procedure`. Un-annotated procedures are not exported. This attribute is new in 0.16 — the protocol's own MASM went from zero uses to 99.
+
+`@auth_script` and `@note_script` already existed in 0.15 and are unchanged.
+
+### Affected Code
+
+```masm
+# After (0.16)
+@account_procedure
+pub proc receive_asset(asset: word)
+ # …
+end
+
+@auth_script
+pub proc auth_tx(auth_args: word)
+ # …
+end
+```
+
+### Migration Steps
+
+1. Add `@account_procedure` to every procedure your component intends to export.
+2. Re-check the resulting `AccountCode` procedure list — a missing annotation shows up as a procedure that silently is not callable, not as a compile error.
+
+---
+
+## `AccountCode::from_parts` is now fallible
+
+### Summary
+
+`AccountCode::from_parts` validated its procedure count with `assert!` in 0.15 and now returns a `Result` instead of panicking.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+pub fn from_parts(mast: Arc, procedures: Vec) -> Self
+
+// After (0.16)
+pub fn from_parts(
+ mast: Arc,
+ procedures: Vec,
+) -> Result
+```
+
+### Migration Steps
+
+Add `?` or explicit error handling at every call site.
+
+---
+
+## `AccountId` no longer converts into `[Felt; 2]`
+
+### Summary
+
+The `impl From for [Felt; 2]` was removed. Use the `prefix()` and `suffix()` accessors instead. Conversions to `[u8; 15]` and `u128` are unchanged.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+let felts: [Felt; 2] = account_id.into();
+```
+
+```rust
+// After (0.16)
+let prefix: AccountIdPrefix = account_id.prefix();
+let suffix: Felt = account_id.suffix();
+```
+
+### Migration Steps
+
+Replace the `into()` conversion with the two accessors. Note `prefix()` returns an `AccountIdPrefix`, not a bare `Felt`.
+
+---
+
+## Account updates move from `AccountDelta` to `AccountPatch`
+
+### Summary
+
+Account **updates** moved from the relative `AccountDelta` to the absolute `AccountPatch`. `ExecutedTransaction` and `AccountUpdateDetails` now carry a patch, and `Account::apply_delta` was replaced by applying a patch.
+
+:::info `AccountDelta` still exists
+This is not a wholesale removal. `AccountDelta` remains, and `TransactionSummary::account_delta()` deliberately still returns one — the signed transaction summary binds a *relative* delta. Only account update representation moved to the absolute patch model. The same split exists on the TypeScript side, where `TransactionSummary.accountDelta()` is unchanged while the result's `accountDelta()` became `accountPatch()`.
+:::
+
+### Affected Code
+
+```rust
+// Before (0.15)
+let delta = executed_tx.account_delta();
+account.apply_delta(&delta)?;
+```
+
+```rust
+// After (0.16)
+let patch = executed_tx.account_patch();
+account.apply_patch(&patch)?;
+
+// Or build a fresh account from the patch instead of mutating one:
+let account = Account::try_from(&patch)?;
+```
+
+`apply_patch` is the direct replacement for `apply_delta` and keeps the same in-place shape, so it is the smaller edit for existing code. The same rename applies further down: `AssetVault::apply_delta` became `apply_patch`, taking an `AccountVaultPatch`.
+
+### Migration Steps
+
+1. Replace `account_delta()` with `account_patch()` on `ExecutedTransaction` and on client transaction results.
+2. Replace `Account::apply_delta(&delta)` with `Account::apply_patch(&patch)`, or construct a new account with `Account::try_from(&patch)`.
+3. Leave `TransactionSummary::account_delta()` call sites alone — that one is intentionally still a delta.
+
+---
+
+## Network accounts require a fee policy
+
+### Summary
+
+`AuthNetworkAccount::new` now takes a `FeePolicyManager` alongside the allowed-note set. The manager carries the fee faucet and the active fee policy, and expands into the policy's components when the auth component is installed — so you do not install policy components separately.
+
+:::note This applies to network accounts and faucets, not ordinary accounts
+Fee *policies* describe how an account that sponsors or charges fees estimates them. An ordinary user account paying a fee does not install one; it supplies fee conversion info per transaction instead. See [Transaction Changes](./transaction-changes#transaction-fees-are-paid-by-the-auth-procedure).
+:::
+
+### Affected Code
+
+```rust
+// After (0.16)
+let manager = FeePolicyManager::builder()
+ .fee_faucet_id(fee_faucet_id)
+ .active_fee_policy(FeePolicy::from(BasicConstantFeePolicy::new()))
+ .build();
+
+let auth = AuthNetworkAccount::new(allowed_notes, manager)?;
+```
+
+`AuthNetworkAccount` no longer converts into a single `AccountComponent` — it expands into several, so install it through `with_components`.
+
+### Migration Steps
+
+1. Build a `FeePolicyManager` with the fee faucet and an active fee policy, registering any alternatives with `allowed_fee_policy` for runtime switching.
+2. Pass it to `AuthNetworkAccount::new`, which is fallible.
+3. Install the auth component with `with_components`, not `with_component`, since it expands to several components.
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `no method named with_auth_component` | Removed | Use `with_component`, or `with_components` for `AuthNetworkAccount`. |
+| `this function takes 1 argument but 2 were supplied` | `AuthSingleSig::new` takes an `Approver` | Wrap in `Approver::new`. |
+| `cannot find type AuthMethod` / `AuthSingleSigAcl` | Removed | See the auth section above. |
+| `expected Result, found AccountCode` | `from_parts` is fallible | Add `?`. |
+| `the trait From is not implemented for [Felt; 2]` | Conversion removed | Use `prefix()` / `suffix()`. |
+| Account commitment differs from 0.15 for the same seed | Component names changed | Expected; re-record the new ID. |
+| Component procedure is not callable but compiles | Missing `@account_procedure` | Annotate the procedure. |
diff --git a/versioned_docs/version-0.16/builder/migration/04-note-changes.md b/versioned_docs/version-0.16/builder/migration/04-note-changes.md
new file mode 100644
index 00000000..eaf5a2ff
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/04-note-changes.md
@@ -0,0 +1,171 @@
+---
+sidebar_position: 4
+title: "Note Changes"
+description: "Standard notes gain typed builders, the per-note asset limit drops from 64 to 16, and mint/burn scripts are unified"
+---
+
+# Note Changes
+
+:::warning Breaking Change
+Every standard note changed shape. `P2idNote`, `P2ideNote`, `SwapNote`, `MintNote`, and `BurnNote` were marker types with a `create(..)` associated function returning a `Note`; they are now real structs built with a typed builder and converted with `.into()`. Separately, **`MAX_ASSETS_PER_NOTE` dropped from 64 to 16**, so any note packing more than 16 assets now fails to build.
+:::
+
+## Quick Fix
+
+```rust
+// Before (0.15)
+let note = P2idNote::create(
+ sender, target, vec![asset], NoteType::Public, attachments, &mut rng,
+)?;
+
+// After (0.16)
+use miden_standards::note::P2idNote;
+
+let note: Note = P2idNote::builder()
+ .sender(sender)
+ .target(target)
+ .assets(vec![asset])
+ .note_type(NoteType::Public)
+ .generate_serial_number(&mut rng)
+ .build()?
+ .into();
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+In 0.15 each standard note was a unit struct — `pub struct P2idNote;` — with a `create` associated function that took every parameter positionally and returned a finished `Note`. In 0.16 each is a real struct holding its fields, built through a `bon` builder and converted to a `Note` with `Into`.
+
+The cost is that every call site changes. The benefit is worth more than a mechanical rewrite, so it is worth pausing on before you reach for search-and-replace: each standard note is now a distinct type, which means your own functions can take a `P2idNote` or a `SwapNote` instead of a bare `Note`. What used to be a runtime check — is this really a P2ID note? — becomes a signature the compiler enforces, and the conversion to `Note` happens once, at the boundary where you actually need one. Optional parameters also stop being positional, and the typed value is inspectable before you convert it.
+
+Watch the asset limit separately. Nothing about it is visible at compile time, so your build stays green and only notes carrying more than 16 assets fail, at the point they are built.
+
+---
+
+## Standard notes are built with typed builders
+
+### Summary
+
+Each standard note struct now exposes `builder()` and converts into `Note` via `From` / `Into`.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+pub fn create(
+ sender: AccountId,
+ target: AccountId,
+ assets: Vec,
+ note_type: NoteType,
+ attachments: NoteAttachments,
+ rng: &mut R,
+) -> Result
+```
+
+```rust
+// After (0.16)
+let p2id = P2idNote::builder()
+ .sender(sender)
+ .target(target)
+ .assets(vec![asset]) // or .asset(x), repeatable
+ .note_type(NoteType::Public)
+ .generate_serial_number(&mut rng) // or .serial_number(word)
+ .build()?;
+let note: Note = p2id.into();
+```
+
+Note two naming details that are easy to get wrong: the setter is `serial_number`, not `serial_num`, and `generate_serial_number(&mut rng)` is the direct replacement for the old `rng` parameter.
+
+`P2ideNote` takes its optional parameters as optional setters rather than positionally:
+
+```rust
+let p2ide = P2ideNote::builder()
+ .sender(sender)
+ .target(target)
+ .assets(vec![asset])
+ .note_type(NoteType::Private)
+ .serial_number(serial_number)
+ .reclaimer(reclaimer_account_id) // optional; defaults to the sender
+ .reclaim_height(BlockNumber::from(n)) // optional
+ .timelock_height(BlockNumber::from(m)) // optional
+ .build()?;
+```
+
+`SwapNote` follows the same pattern. In 0.15 `SwapNote::create` returned a `(Note, NoteDetails)` tuple carrying the payback details; in 0.16 you build the `SwapNote` and read its parts from the typed value.
+
+The same builder treatment applies to `MintNote`, `BurnNote`, `PswapNote`, and `TxFeeNote`, along with the configuration notes (`AllowlistConfigNote`, `OwnerConfigNote`, `FaucetMetadataConfigNote`, `NetworkAccountConfigNote`, `FaucetPolicyConfigNote`, `MinBurnAmountConfigNote`).
+
+### Migration Steps
+
+1. Replace every `XNote::create(..)` call with the corresponding `XNote::builder()` chain ending in `.build()?`, then `.into()` wherever a `Note` is required.
+2. Replace the trailing `rng` argument with `.generate_serial_number(&mut rng)`.
+3. Use `.assets(..)` for a collection or `.asset(..)` repeatedly for individual assets.
+4. For `P2ideNote`, set only the optional parameters you actually need. `reclaimer` still defaults to the sender, so existing "sender can reclaim" behaviour is preserved without changes.
+5. Push the `.into()` outward while you are here. Any function of yours that only ever handles one kind of note can take the typed note instead of a `Note`, which turns a runtime check into a compile-time guarantee; convert once, where a `Note` is genuinely needed.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `no function or associated item named create` | Replaced by the builder | Use `XNote::builder()`. |
+| `no method named serial_num` | Setter renamed | Use `serial_number` or `generate_serial_number`. |
+| `expected Note, found P2idNote` | The builder yields the typed note | Add `.into()`. |
+| `a P2ID note must contain at least one asset` | Built with no assets | Add at least one asset. |
+
+---
+
+## `MAX_ASSETS_PER_NOTE` dropped from 64 to 16
+
+### Summary
+
+The protocol limit on assets carried by a single note fell from 64 to 16.
+
+### Affected Code
+
+```diff
+- pub const MAX_ASSETS_PER_NOTE: usize = 64;
++ pub const MAX_ASSETS_PER_NOTE: usize = 16;
+```
+
+This is enforced by `NoteAssets::new`, so it surfaces as a `NoteError` at build time rather than a compile error.
+
+### Migration Steps
+
+1. Audit any code path that batches assets into a single note and cap it at 16.
+2. If you previously relied on packing up to 64 assets, split the payload across multiple notes.
+3. If you compute a batch size from the constant rather than hard-coding it, no change is needed beyond a rebuild.
+
+---
+
+## `MINT` and `BURN` are unified across faucet kinds
+
+### Summary
+
+One `mint.masm` and one `burn.masm` script now serve both fungible and non-fungible faucets, with the variant carried in the note storage. **The script roots changed**, so any hard-coded or cached root is now wrong.
+
+### Migration Steps
+
+1. Recompute and re-store any cached standard note script roots.
+2. Remove per-faucet-kind branching that selected between separate mint or burn scripts.
+
+---
+
+## Other note changes
+
+- **`PswapNote` (partial swap)** gained a minimum-fill parameter, and its fields were renamed.
+- **`NoteFile` was reworked and moved to `miden-standards`**, with variants keyed on `NoteId`, `ExpectedNote`, and `Committed`. This mostly affects client code — see [Client Changes](./client-changes).
+- **`NoteTag`** moved under `miden::standards::note::note_tag` in MASM. In the released `0.16.0-rc` line it is still reachable at `miden::standards::note_tag`.
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `no function or associated item named create` | Notes use builders now | Rewrite with `XNote::builder()`. |
+| `NoteError` about exceeding asset limits | Limit is now 16 | Split across multiple notes. |
+| Note script root mismatch for mint or burn | Scripts were unified | Recompute the roots. |
+| `expected Note, found MintNote` | Builder returns the typed note | Add `.into()`. |
diff --git a/versioned_docs/version-0.16/builder/migration/05-asset-vault-faucet.md b/versioned_docs/version-0.16/builder/migration/05-asset-vault-faucet.md
new file mode 100644
index 00000000..9bf7a24b
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/05-asset-vault-faucet.md
@@ -0,0 +1,158 @@
+---
+sidebar_position: 5
+title: "Assets, Vault & Faucet Changes"
+description: "AssetVaultKey becomes AssetId, the old AssetId becomes AssetClass, and faucet factories split by authentication scheme"
+---
+
+# Assets, Vault & Faucet Changes
+
+:::warning Breaking Change
+The asset model was renamed one level down. What was `AssetVaultKey` is now **`AssetId`**, and what was `AssetId` is now **`AssetClass`**. Because the name `AssetId` survives with a different meaning, a careless search-and-replace will compile and be wrong — do the `AssetId` → `AssetClass` rename first. Separately, the single `create_fungible_faucet` factory split into six auth-specific factories.
+:::
+
+## Quick Fix
+
+```rust
+// Before (0.15)
+let key: AssetVaultKey = asset.vault_key();
+let key = AssetVaultKey::new_fungible(faucet_id, callback_flag);
+
+// After (0.16)
+let id: AssetId = asset.id();
+let id = AssetId::new_fungible(faucet_id); // callback flag now lives on the AccountId
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+The rename reflects a conceptual correction. The per-asset vault key is the thing that actually identifies an asset, so it took the name `AssetId`; the faucet-level identifier it used to share a name with describes a *class* of assets, so it became `AssetClass`.
+
+This is the most dangerous rename in the release precisely because it is not a removal. `AssetId` still exists after the upgrade, so code referring to it keeps compiling while silently meaning something different. Rename in the right order and let the compiler find the rest.
+
+:::note Several related types did not change
+`AssetAmount`, `AssetComposition`, and `AssetCallbackFlag` all existed in 0.15 and are unchanged. `AssetVault::get_balance` already returned `AssetAmount` in 0.15 — only its parameter type was renamed. If you saw `AssetAmount` described as new, that applies to the [client surface](./client-changes), not the protocol.
+:::
+
+---
+
+## `AssetVaultKey` → `AssetId`, and `AssetId` → `AssetClass`
+
+### Summary
+
+The vault key type was renamed to `AssetId`, the previous `AssetId` became `AssetClass`, and `Asset::vault_key()` became `Asset::id()`. `AssetIdHash` is the corresponding hash type.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+let key: AssetVaultKey = asset.vault_key();
+let balance: AssetAmount = vault.get_balance(vault_key)?;
+let key = AssetVaultKey::new_fungible(faucet_id, callback_flag);
+```
+
+```rust
+// After (0.16)
+let id: AssetId = asset.id();
+let balance: AssetAmount = vault.get_balance(asset_id)?;
+let id = AssetId::new_fungible(faucet_id);
+// or, fully explicit:
+let id = AssetId::new(asset_class, faucet_id, composition);
+```
+
+Note that `AssetId::new_fungible` **no longer takes a callback flag**. Whether a faucet's assets trigger callbacks is encoded in the account ID itself, set at construction time via `AccountBuilder::with_asset_callbacks`.
+
+`Asset` itself is unchanged in shape — still an enum with `Fungible` and `NonFungible` variants — and `FungibleAsset::new(faucet_id, amount)` keeps its signature.
+
+### Migration Steps
+
+1. Rename `AssetId` → `AssetClass` **first**, throughout your codebase.
+2. Then rename `AssetVaultKey` → `AssetId`.
+3. Replace `asset.vault_key()` with `asset.id()`.
+4. Drop the callback-flag argument from `new_fungible` calls; set it on the account instead with `with_asset_callbacks`.
+5. Re-index any persisted vault or asset data. Serialized asset identifiers are not compatible across the rename.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `cannot find type AssetVaultKey` | Renamed | Use `AssetId`. |
+| `no method named vault_key` | Renamed | Use `id()`. |
+| `this function takes 1 argument but 2 were supplied` on `new_fungible` | Callback flag removed | Drop it; set `with_asset_callbacks` on the account. |
+| Type mismatch where `AssetId` used to work | `AssetId` now means the vault key | The old meaning is `AssetClass`. |
+
+---
+
+## Faucet factories split by authentication scheme
+
+### Summary
+
+`create_fungible_faucet` took an `AuthMethod` and an `AccessControl` argument and dispatched internally. Since `AuthMethod` was removed (see [Account Changes](./account-changes#approver-and-approverset-replace-raw-key-arguments)), the factory split into one function per authentication scheme, each taking a concrete auth component.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+pub fn create_fungible_faucet(
+ init_seed: [u8; 32],
+ faucet: FungibleFaucet,
+ account_type: AccountType,
+ auth_method: AuthMethod,
+ access_control: AccessControl,
+ token_policy_manager: TokenPolicyManager,
+) -> Result
+```
+
+```rust
+// After (0.16)
+pub fn create_singlesig_user_fungible_faucet(
+ init_seed: [u8; 32],
+ faucet: FungibleFaucet,
+ auth_component: AuthSingleSig,
+ token_policy_manager: TokenPolicyManager,
+ account_type: AccountType,
+) -> Result
+```
+
+Note that the parameter **order** changed as well as the parameter list — `account_type` moved to the end.
+
+The full set of factories:
+
+| Faucet kind | v0.16 factory |
+| --- | --- |
+| Fungible, single signature | `create_singlesig_user_fungible_faucet` |
+| Fungible, multisig | `create_multisig_user_fungible_faucet` |
+| Fungible, guarded multisig | `create_guarded_user_fungible_faucet` |
+| Fungible, network account | `create_network_fungible_faucet` |
+| Non-fungible, user account | `create_user_non_fungible_faucet` |
+| Non-fungible, network account | `create_network_non_fungible_faucet` |
+
+Non-fungible faucet factories are new in this release; 0.15 shipped only the fungible factory.
+
+### Migration Steps
+
+1. Choose the factory matching your authentication scheme and pass a concrete auth component instead of an `AuthMethod`.
+2. Drop the `access_control` argument. The factories install `Authority::AuthControlled` and the pausable components for you.
+3. Check the argument order — `account_type` is now last.
+4. Expect a different account ID for a faucet rebuilt from the same seed, since the component set and names changed.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `cannot find function create_fungible_faucet` | Split into per-scheme factories | Use the matching factory from the table. |
+| `cannot find type AuthMethod` | Removed | Pass a concrete auth component. |
+| Arguments of the wrong type | Parameter order changed | `account_type` moved to the end. |
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `cannot find type AssetVaultKey` | Renamed to `AssetId` | Rename, after renaming old `AssetId` to `AssetClass`. |
+| Silent behaviour change around asset identity | `AssetId` kept its name with a new meaning | Audit every `AssetId` reference. |
+| Persisted vault lookups miss after upgrading | Asset identifier serialization changed | Re-index persisted vault data. |
+| `cannot find function create_fungible_faucet` | Factories split | Use the auth-specific factory. |
diff --git a/versioned_docs/version-0.16/builder/migration/06-transaction-changes.md b/versioned_docs/version-0.16/builder/migration/06-transaction-changes.md
new file mode 100644
index 00000000..40c32a1b
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/06-transaction-changes.md
@@ -0,0 +1,314 @@
+---
+sidebar_position: 6
+title: "Transaction Changes"
+description: "Fees move into the auth procedure, transaction inputs are sealed before submission, and TransactionSummary binds the reference block — so multi-party signing needs a ChainAnchor"
+---
+
+# Transaction Changes
+
+:::warning Breaking Change
+Transaction fees moved out of the kernel epilogue and into the **authentication procedure**. On a chain with a non-zero `verification_base_fee`, transactions signed by `AuthSingleSig` or `AuthMultisig` must commit fee conversion info through the transaction's auth args, and the paying account must hold the fee asset. Separately, transaction inputs are now **sealed (encrypted)** before submission, so a 0.16 client cannot submit to a 0.15 node or vice versa.
+:::
+
+## Quick Fix
+
+```rust
+// After (0.16) — declare how the fee is paid
+use miden_client::account::component::FeeConversionInfo;
+
+let info = FeeConversionInfo::one_to_one(fee_faucet_id);
+let request = TransactionRequestBuilder::new()
+ .fee_conversion_info(info, salt) // salt: Word
+ .build()?;
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+The fee change is the largest behavioural change in the release, and it is easy to under-estimate because it does not necessarily break your build. If you target a chain that charges no fee, nothing changes. If you target a chain that does, transactions that used to succeed now fail unless the request declares fee conversion info.
+
+The reason for the move is visible in the standard auth component's own documentation: paying the fee *before* the transaction summary is created means the fee note and the vault withdrawal funding it are covered by the signature. Under the old model the kernel deducted the fee outside anything the user signed.
+
+The sealing change is a hard compatibility boundary rather than an API change — it mostly costs you a coordinated upgrade rather than a code edit.
+
+The summary change is the quiet one. Its constructor break is mechanical, but the binding it introduces silently invalidates any flow that collects signatures on one client and executes on another; that code still compiles and only fails once it runs.
+
+---
+
+## Transaction fees are paid by the auth procedure
+
+### Summary
+
+In 0.15 the kernel computed and burned the fee from the native account's vault automatically. In 0.16 the auth procedure reads a `FeeConversionInfo` blob out of the auth args, computes the fee, and emits a `TX_FEE` note to the fee faucet.
+
+The standard auth components already do this for you — `AuthSingleSig`'s MASM calls `fee::load_conversion_info` and `fee::pay_fee` before authenticating. What you must supply is the auth args.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+// Nothing to declare: the kernel handled the fee.
+let request = TransactionRequestBuilder::new().build()?;
+let fee = executed_tx.fee();
+```
+
+```rust
+// After (0.16)
+use miden_client::account::component::FeeConversionInfo;
+
+// Pay in the chain's native fee asset at rate 1/1:
+let info = FeeConversionInfo::one_to_one(fee_faucet_id);
+// or specify an explicit conversion rate:
+let info = FeeConversionInfo::new(fee_faucet_id, rate_num, rate_den)?;
+
+let request = TransactionRequestBuilder::new()
+ .fee_conversion_info(info, salt)
+ .build()?;
+```
+
+The exact signatures:
+
+```rust
+FeeConversionInfo::new(faucet_id: AccountId, rate_num: u64, rate_den: u64) -> Result
+FeeConversionInfo::one_to_one(faucet_id: AccountId) -> Self
+commit_fee_conversion_info(conversion_info: FeeConversionInfo, salt: Word) -> (Word, Vec)
+
+// on the client builder:
+pub fn fee_conversion_info(self, conversion_info: FeeConversionInfo, salt: Word) -> Self
+```
+
+:::caution The `salt` argument is mandatory and undocumented upstream
+`fee_conversion_info` takes a second `salt: Word` parameter that the changelog does not mention. Code written from the changelog alone will not compile.
+:::
+
+`FeeConversionInfo` is reachable at `miden_client::account::component::FeeConversionInfo` — it is *not* exported from `miden_client::auth`, where you would naturally look first. Adding a direct `miden-standards` dependency also works.
+
+### When it is required, and when it is rejected
+
+Only auth components that actually read the auth args honour the conversion info. The client validates this **before execution** rather than silently paying in the native asset:
+
+```rust
+// miden_client::transaction::TransactionRequestError
+FeeConversionInfoUnsupported(String)
+// "the request declares fee conversion info but the account's auth component {0} does not read it"
+```
+
+The check passes only for `AuthSingleSig` and `AuthMultisig`. Declaring fee conversion info for any other auth component — `NoAuth`, for example — is rejected. If the request does not declare fee conversion info at all, the check is skipped, so existing code that never calls the builder method is unaffected by this validation.
+
+Because `fee_conversion_info` consumes the auth arg, it **conflicts with a manually set `auth_arg`** — whichever is applied last wins.
+
+### Migration Steps
+
+1. On a chain with a non-zero `verification_base_fee`, call `fee_conversion_info(info, salt)` on every request signed by an `AuthSingleSig` or `AuthMultisig` account. Use `FeeConversionInfo::one_to_one(fee_faucet_id)` for the native fee asset.
+2. Ensure the paying account holds a balance of the fee asset — the auth procedure debits it.
+3. Do **not** call `fee_conversion_info` for accounts using any other auth component.
+4. If you set `auth_arg` manually, pick one or the other.
+5. Replace `executed_tx.fee()` with inspection of the `TX_FEE` output note. `ExecutedTransaction::compute_fee()` still exists but only under the `testing` feature — do not use it in production.
+6. If you wrote a **custom auth component** in MASM, it must now call `miden::standards::fee::load_conversion_info` followed by `miden::standards::fee::pay_fee`, or the transaction will fail fee validation.
+7. On a zero-fee chain, no change is required.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `this function takes 2 arguments but 1 was supplied` | The `salt` parameter | Pass a `Word` salt. |
+| `FeeConversionInfoUnsupported` | Auth component does not read auth args | Only declare it for `AuthSingleSig` / `AuthMultisig`. |
+| `cannot find FeeConversionInfo in miden_client::auth` | Exported elsewhere | Use `miden_client::account::component::FeeConversionInfo`. |
+| Transaction aborts on a fee-charging chain | No fee conversion info declared, or no fee asset balance | Declare the info and fund the account. |
+| Custom auth component transaction fails fee validation | MASM does not pay the fee | Call `fee::load_conversion_info` then `fee::pay_fee`. |
+
+---
+
+## Transaction inputs are sealed before submission
+
+### Summary
+
+Transaction inputs are encrypted ("sealed") before being submitted. The RPC layer gained a `get_transaction_encryption_key` method plus a `miden_client::rpc::encryption` module.
+
+**This is a hard compatibility boundary.** A 0.16 node rejects plaintext submissions and an older node rejects sealed ones, so the client and node must be upgraded together.
+
+:::note Most applications do not change any code here
+`Client::submit_proven_transaction` keeps its 0.15 signature exactly — it still takes `impl Into`, and sealing happens beneath it. Only the `NodeRpcClient` **trait** methods changed to take `SealedTransactionInputs`, so this is a source-breaking change solely for code that implements that trait.
+:::
+
+The requirement this does impose on every application is a **sync before submitting**: sealing resolves an encryption key against the chain state, so a client that has not synced the genesis and chain-tip headers fails with `ClientError::ChainValidationError`.
+
+### Migration Steps
+
+1. Upgrade your node and client together. There is no configuration that makes a 0.16 client talk to a 0.15 node.
+2. Ensure the client has synced before submitting, or key resolution fails with `ChainValidationError`.
+3. If you implement `NodeRpcClient` yourself, update `submit_proven_transaction` and `submit_proven_batch` to take `SealedTransactionInputs`, and add `get_transaction_encryption_key`.
+
+:::note Key types are not re-exported from the encryption module
+The changelog states that `miden_client::rpc::encryption` re-exports the validator DSA key types. It does not — that module contains no `pub use` statements. Reach them via `miden_client::crypto::{ecdsa_k256_keccak, eddsa_25519_sha512}`.
+:::
+
+---
+
+## `TransactionSummary` binds the reference block, expiration, and user params
+
+### Summary
+
+`TransactionSummary::new` replaced its single `salt` parameter with a block commitment, an expiration delta, and a structured user-parameters value.
+
+### Affected Code
+
+```rust
+// Before (0.15)
+pub fn new(
+ account_delta: AccountDelta,
+ input_notes: InputNotes,
+ output_notes: RawOutputNotes,
+ salt: Word,
+) -> Self
+```
+
+```rust
+// After (0.16)
+pub fn new(
+ account_delta: AccountDelta,
+ input_notes: InputNotes,
+ output_notes: RawOutputNotes,
+ block_commitment: Word,
+ expiration_delta: u16,
+ user_params: TransactionSummaryUserParams,
+) -> Self
+```
+
+`TransactionSummaryUserParams` carries seven field elements. On the TypeScript side the corresponding accessor renamed from `TransactionSummary.salt()` to `TransactionSummary.userParams()`.
+
+The reference block commitment is included because it determines the fee parameters, and therefore the fee amount deducted; the expiration delta is included so the signature covers it.
+
+:::info `TransactionSummary` still uses `AccountDelta`
+Note the first parameter. While account *updates* moved to the absolute `AccountPatch` model (see [Account Changes](./account-changes#account-updates-move-from-accountdelta-to-accountpatch)), the signed transaction summary deliberately still binds a relative `AccountDelta`. Do not rewrite these call sites.
+:::
+
+### Migration Steps
+
+1. Replace the `salt` argument with the block commitment, expiration delta, and user params.
+2. In TypeScript, replace `summary.salt()` with `summary.userParams()`.
+
+---
+
+## Collecting signatures across clients requires a `ChainAnchor`
+
+### Summary
+
+The binding above has a consequence that never surfaces as a compile error. Because the summary commits to the reference block, a summary derived at one block only authorizes an execution at *that* block. Clients execute at their own sync height by default, so in any flow that derives a summary, collects signatures, and executes later — a multisig proposal, offline co-signing — the proposer, each co-signer, and the executor sit at different heights and derive three different summaries. The collected signatures do not apply.
+
+Code written for 0.15 compiles unchanged and fails at run time, which makes this the easiest change in the release to miss.
+
+`ChainAnchor` is the remedy. It pins execution to a chosen reference block, so the same summary reproduces on a client at any height: the proposer captures one, ships it alongside the summary, and every party executes against it.
+
+:::note Newer than the versions pinned in Quick Upgrade
+`ChainAnchor` landed after the versions in [Quick Upgrade](./#quick-upgrade). It needs `miden-client` `0.16.0-rc.2` and `@miden-sdk/miden-sdk` / `@miden-sdk/react` `0.16.0-rc.3` or later.
+:::
+
+### Affected Code
+
+```rust
+// Before (0.15)
+// The proposer and the executor each ran this at their own sync height,
+// and the summary the co-signers signed still reproduced.
+let result = client.execute_transaction(account_id, request).await?;
+```
+
+```rust
+// After (0.16)
+use miden_client::transaction::ChainAnchor;
+use miden_client::{Deserializable, Serializable};
+
+// Proposer: pin the reference block, and ship the anchor with the summary.
+let anchor = client.chain_anchor_for_request(&request).await?;
+let bytes = anchor.to_bytes();
+
+// Co-signer and executor: rebuild it and execute at the same block.
+let anchor = ChainAnchor::read_from_bytes(&bytes)?;
+let result = client.execute_transaction_at(account_id, request, anchor).await?;
+```
+
+```typescript
+// After (0.16) — Web SDK
+const anchor = await client.transactions.captureAnchor(request);
+
+const summary = await client.transactions.preview({
+ operation: "custom", account, request, anchor,
+});
+// ... collect signatures over `summary`, shipping `anchor.serialize()` ...
+
+await client.transactions.submit(account, request, { anchor });
+```
+
+```tsx
+// After (0.16) — React
+const { captureAnchor, anchoredRequest } = useChainAnchor();
+const { preview } = usePreview();
+const { execute } = useTransaction();
+
+const anchor = await captureAnchor({ request: buildRequest });
+const summary = await preview({ accountId, request: anchoredRequest, anchor });
+// ... collect signatures ...
+await execute({ accountId, request: anchoredRequest, anchor });
+```
+
+Single-client flows need no anchor. `execute_transaction` and an `anchor`-less `submit` keep their 0.15 behaviour of executing at the current sync height, which is correct whenever the client deriving the summary is also the one executing.
+
+### Verify an anchor you did not capture
+
+An anchor arrives from whoever proposed the transaction. Deserialization rejects malformed bytes, so the remaining risk is a well-formed anchor pinned to the wrong block. Compare its commitment against the one signed into the summary before executing:
+
+```typescript
+if (anchor.commitment().toHex() !== summary.blockCommitment().toHex()) {
+ throw new Error("anchor does not match the signed summary");
+}
+```
+
+The Rust accessor is `ChainAnchor::block_commitment`. Note what this check does and does not prove: it establishes that the anchor and the summary agree, not that either is what you meant to sign, since the proposer supplies both. Inspect the summary's effects separately.
+
+### Migration Steps
+
+1. Find every flow that derives a transaction summary on one client and executes on another — multisig proposals and offline co-signing are the common cases. Single-client flows are unaffected.
+2. Capture an anchor at proposal time with `chain_anchor_for_request` (Rust), `transactions.captureAnchor` (Web), or `useChainAnchor` (React), and ship the serialized bytes alongside the summary.
+3. Re-derive the summary at the anchor when verifying a proposal, rather than at the local sync height: `executeForSummaryAt` on the WASM client, or `preview({ …, anchor })` at the ergonomic layers. Deriving it locally produces a different summary and the comparison always fails.
+4. Execute at the anchor: `execute_transaction_at`, or the `anchor` option on `executeRequest` / `submit` / `useTransaction().execute`.
+5. Check a received anchor against `summary.blockCommitment()` before using it.
+6. In React, preview and execute against the `anchoredRequest` the hook returns, not the value you passed in. Re-resolving a request factory builds a different transaction — anything creating an output note draws a fresh serial number — so the anchor would pin a request nobody executes.
+7. Budget for expiry. The expiration delta counts from the anchored block, not from the executing client's height, so an anchor that sat too long during signature collection fails with `AnchoredTransactionExpired`.
+
+:::caution Rust consumes the anchor; JavaScript does not
+`execute_transaction_at` takes the `ChainAnchor` by value, so capture it again — or clone it — for a preview-then-execute sequence. The wasm bindings take it by reference, so one JS handle survives both calls. Because a JS anchor carries a partial blockchain, call `anchor.free()` when done rather than leaving it to the finalizer.
+:::
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| Collected signatures are rejected, with no compile error | Each party derived the summary at its own sync height | Capture an anchor and derive and execute at it. |
+| `TRANSACTION_ALREADY_AUTHORIZED` | The transaction needs no further signatures | Submit it with `execute` / `submit` instead of previewing. |
+| `AnchoredTransactionExpired` | The anchor aged past the transaction's expiration during signature collection | Re-capture the anchor and re-collect. |
+| `ChainAnchorError` on execution | An authenticated input note's creation block is not tracked by the anchor | Capture the anchor with `chain_anchor_for_request`, which tracks those blocks. |
+| `INVALID_CHAIN_ANCHOR` | A sync landed mid-capture | Retry the capture. |
+| `captureAnchor is not a function` | Web SDK older than `0.16.0-rc.3` | Bump `@miden-sdk/miden-sdk` and `@miden-sdk/react` together. |
+
+---
+
+## Other transaction changes
+
+- **Proving is synchronous; execution stays asynchronous.** Adjust any code that awaited the proving step.
+- **`ExecutedTransaction::account_delta()` became `account_patch()`**, matching the account update model. See [Account Changes](./account-changes#account-updates-move-from-accountdelta-to-accountpatch).
+- **`ExecutedTransaction::compute_fee()` is gated behind the `testing` feature.** Production fee figures come from the `TX_FEE` note.
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| Node rejects a submitted transaction | Client and node versions mixed | Upgrade both to 0.16. |
+| `this function takes 6 arguments but 4 were supplied` | `TransactionSummary::new` changed | Pass block commitment, expiration delta, and user params. |
+| `no method named salt` on a summary | Renamed | Use `userParams()`. |
+| `no method named fee` on an executed transaction | Fees now flow through the `TX_FEE` note | Inspect the output note. |
+| `no method named account_delta` on an executed transaction | Renamed | Use `account_patch()`. |
diff --git a/versioned_docs/version-0.16/builder/migration/07-client-changes.md b/versioned_docs/version-0.16/builder/migration/07-client-changes.md
new file mode 100644
index 00000000..07e96f98
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/07-client-changes.md
@@ -0,0 +1,346 @@
+---
+sidebar_position: 7
+title: "Client Changes"
+description: "Rust, Web, React and CLI client changes, plus the mandatory local store recreation"
+---
+
+# Client Changes
+
+:::danger Your local store must be recreated
+Every pre-0.16 SQLite store is rejected. There is no migration path: delete the database and re-sync. Browser applications are handled automatically — the IndexedDB store detects the version bump and wipes itself on first open. In both cases **any state that existed only locally is lost**, including records for accounts not yet committed on-chain.
+:::
+
+:::warning Client and node must be upgraded together
+0.16 clients seal (encrypt) transaction inputs before submission. A 0.16 node rejects plaintext submissions and an older node rejects sealed ones, so the two cannot be mixed. Upgrade both.
+:::
+
+## Quick Fix
+
+```bash
+# CLI: the send subcommand was renamed
+miden-client transfer -t -a 100:: -n private
+```
+
+```rust
+// Rust: account updates are absolute patches now
+let patch = tx_result.account_patch();
+```
+
+```typescript
+// Web: same split on the TypeScript side
+const patch = txResult.accountPatch();
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+The client changes fall into four groups. The **store break** is the one that costs users data, and it is unavoidable. The **fee and sealing changes** are covered in [Transaction Changes](./transaction-changes) — they surface here as a new builder method and a node-version requirement. The **rename churn** (`account_delta` → `account_patch`, `send` → `transfer`, and friends) is mechanical. And a handful of **silent behavioural changes** — the `call` argument counting, the transaction summary display, `notes.sendPrivate` requiring a scan height — will not fail your build but will change what your application does.
+
+---
+
+## (Store) Every pre-0.16 SQLite store must be recreated
+
+### Summary
+
+The store schema changed substantially: account SMT forest tables were added, account IDs and all digest columns were retyped from hex `TEXT` to `BLOB`, a `script_root` index was added, and the `migrations` table was dropped in favour of a schema fingerprint.
+
+### Affected Code
+
+A store written by miden-client 0.15.5 fails to open with:
+
+```text
+Migration error: Attempt to migrate a database with a migration number that is too high
+```
+
+:::note This is not the error the changelog names
+The changelog says opening a pre-0.16 database fails with `SchemaHashMismatch`. In practice a 0.15.5 store sits at `user_version = 2`, and because 0.16 defines only one migration the fingerprint check is skipped entirely — the failure surfaces from the migration layer instead. `SchemaHashMismatch` is only reached by a store at `user_version = 1`. Both paths fail; only the message differs.
+:::
+
+Beyond the account ID retyping, the schema diff also shows the `latest_account_assets` and `historical_account_assets` column `vault_key` renamed to `asset_id` (following the [protocol rename](./asset-vault-faucet)), a new unique index on `tags(tag, source)`, and *all* digest columns retyped to `BLOB` — `account_commitment`, `note_id`, `nullifier`, `script_root`, `recipient_digest`, and storage keys and values.
+
+### Migration Steps
+
+1. Delete the store database and let the client recreate it, then re-sync.
+2. Export anything you need to keep **before** upgrading — private note files in particular.
+3. Browser applications need no action; the IndexedDB store resets itself when the client's minor version increases.
+4. If you implement a custom `Store`, note that `insert_block_header` now takes a `nodes` argument, `insert_partial_blockchain_nodes` was removed, and the new `NoteFilter::ScriptRoots` variant makes existing exhaustive matches fail to compile.
+
+---
+
+## (Rust) Account updates use `AccountPatch`
+
+`TransactionResult::account_delta()` became `account_patch()`, and `Account::apply_delta` was replaced by construction from a patch. `TransactionSummary::account_delta()` is deliberately unchanged. This is covered in full under [Account Changes](./account-changes#account-updates-move-from-accountdelta-to-accountpatch).
+
+One import detail specific to the client: in 0.15 `AccountStorageDelta` lived in `miden_client::asset`; the 0.16 replacement `AccountStoragePatch` lives in `miden_client::account`. The module moved as well as the name. `StorageMapDelta` and `StorageSlotDelta` were dropped from `miden_client::asset` alongside it, while `AccountVaultDelta` remains there.
+
+---
+
+## (Rust) Fee conversion info on the transaction request
+
+`TransactionRequestBuilder::fee_conversion_info(info, salt)` is new and required on fee-charging chains for `AuthSingleSig` and `AuthMultisig` accounts. See [Transaction Changes](./transaction-changes#transaction-fees-are-paid-by-the-auth-procedure) for the full flow, including the mandatory `salt` argument that the changelog omits.
+
+---
+
+## (Rust) Chain-anchored execution for multi-party signing
+
+`Client::chain_anchor_for_request` and `Client::execute_transaction_at` are new, and they are not optional for any flow that derives a transaction summary on one client and executes it on another. Since the summary now binds the reference block, the parties must agree on that block or the collected signatures do not apply — see [Transaction Changes](./transaction-changes#collecting-signatures-across-clients-requires-a-chainanchor) for the full flow.
+
+Two details specific to the client: `ClientError` gained a `ChainAnchorError` variant, so an exhaustive match over it no longer compiles; and both methods arrived in `0.16.0-rc.2`, one release after the version pinned in [Quick Upgrade](./#quick-upgrade).
+
+---
+
+## (Rust) Fungible amounts use `AssetAmount`
+
+### Summary
+
+The client surface switched from raw `u64` to `AssetAmount` for fungible amounts. `AccountReader::get_balance` returns `AssetAmount`, and the token conversion helpers (`tokens_to_base_units`, `base_units_to_tokens`) and `build_pswap_consume` follow.
+
+### Migration Steps
+
+1. Wrap raw amounts with `AssetAmount`, or unwrap with the provided accessor where you need a `u64`.
+2. Handle `TokenParseError::InvalidAmount` where you parse user-supplied amounts.
+
+---
+
+## (Rust) Auth and faucet re-exports changed
+
+### Summary
+
+`AuthMethod` and `AuthSingleSigAcl` were removed, and the single fungible faucet factory split into auth-specific factories — see [Assets, Vault & Faucet Changes](./asset-vault-faucet#faucet-factories-split-by-authentication-scheme). Note that the client re-exports only two of the six upstream factory functions; for the rest, depend on `miden-standards` directly.
+
+The account policy components were also renamed, a change absent from the changelog and found by diffing the re-export lists:
+
+```diff
+- AllowlistOwnerControlled
++ AllowlistManager
+- BlocklistOwnerControlled
++ BlocklistManager
+```
+
+---
+
+## (Rust) Note screening methods renamed
+
+### Summary
+
+`NoteScreener::can_consume` became `get_consumability`, and `can_consume_batch` became `get_batch_consumability`. A new `get_batch_consumability_for_account` was added. `Client::get_consumable_notes` keeps its signature — passing a single account is now screened more efficiently, but nothing about the call changes.
+
+:::note The rename is cosmetic
+The changelog justifies it by saying the methods now return a consumption status per account rather than a boolean. They never returned a boolean — the return type is identical in 0.15 and 0.16. Rename the call sites; do not change how you handle the result.
+:::
+
+---
+
+## (Rust) Debug mode removed
+
+`DebugMode`, `ClientBuilder::in_debug_mode`, `Client::in_debug_mode`, and the `MIDEN_DEBUG` environment variable were all removed. The VM replaced the flag-gated `debug.*` decorators with `miden::core::debug` procedures that print unconditionally, so there is nothing left to gate. See [MASM Changes](./masm-changes#debug-and-trace-decorators-removed).
+
+---
+
+## (Rust) Other library changes
+
+- **`StateSyncUpdate` is immutable** — construct with `from_parts`, read through accessors, and destructure with `into_parts`. `PartialBlockchainUpdates::insert` lost its nodes argument, and `extend_authentication_nodes` was added.
+- **`miden_client::assembly::Library` was removed.** Use `miden_client::vm::Package`. Note that `Package` is not new — it was already re-exported in 0.15; only the `Library` removal is a 0.16 change.
+- **`Client::fetch_all_private_notes` was removed**, replaced by note transport syncing.
+- **`TransactionRecord` gained a private field**, so struct literal construction no longer compiles.
+- **`send_notes` reads its payload from the advice provider** and requires a payload-commitment script argument. A `script_arg` passed alongside a `SendNotes` template is ignored.
+- **`AccountSmtForest` is generic over its backend**, and the root-staging API was removed.
+- **Response verification moved into `VerifyingRpcClient`.** The built-in gRPC constructors now wrap the transport in it automatically, but `ClientBuilder::rpc` does **not** — passing your own `NodeRpcClient` compiles and runs while silently losing response verification. Wrap it yourself with `VerifyingRpcClient::new(..)`.
+
+---
+
+## (Web) Package and API changes
+
+Bump `@miden-sdk/miden-sdk` and `@miden-sdk/react` together — mixing 0.15 and 0.16 packages will not link against the shared WASM ABI.
+
+| Change | Migration |
+| --- | --- |
+| `ClientOptions.debugMode` removed; `createClient*` drops the trailing `debugMode` argument | Delete the option and the argument. |
+| `accountDelta()` → `accountPatch()`; `AccountStorageDelta` removed | Rename. `TransactionSummary.accountDelta()` is unchanged. |
+| `TransactionSummary.salt()` → `userParams()` | Rename; the value is now seven field elements. |
+| `transactions.preview(..)` returns only a summary while authorization is pending | Do not expect full transaction details from a preview. |
+| A summary derived on one client no longer reproduces on another | Capture a `ChainAnchor` and pass it as the `anchor` option. See [Transaction Changes](./transaction-changes#collecting-signatures-across-clients-requires-a-chainanchor). |
+| `notes.sendPrivate` requires `scanAfterBlockNum`; new `notes.sendPrivateOutput` | Pass a scan height. |
+| `notes.fetchPrivate({ mode: "all" })` removed | Use note transport syncing. |
+| `AccountComponent.createNetworkAuth` → `createNetworkAuthComponents` | Rename; it now returns several components. |
+| `FungibleAsset.withCallbacks(flag)` removed | Set callbacks on the account at construction. |
+| P2ID and P2IDE notes must carry at least one asset | Building an empty note now throws. |
+| Production WASM strips MASM debug metadata | Expect less detail in production stack traces. |
+| Notes carrying a `NetworkAccountTarget` are priced via a foreign procedure invocation into the target | Behavioural; see the note below. |
+
+Additive: `notes.list({ scriptRoots })`, `NoteScript.networkAccountConfig()`, `NoteScript.feeSponsorship()`, and `compile.component({ namespace })`.
+
+Additive in `0.16.0-rc.3`, one release later: `transactions.captureAnchor(request)`, an `anchor` option on `preview` / `executeRequest` / `submit`, the wasm-level `chainAnchorForRequest` / `executeTransactionAt` / `executeForSummaryAt`, and `TransactionSummary.blockCommitment()` / `expirationDelta()`.
+
+If you author MASM through the Web SDK, the language changes apply to you as well — `@account_procedure` annotations, `mod` declarations, and the new import syntax. See [MASM Changes](./masm-changes).
+
+:::caution Unverified
+The `NetworkAccountTarget` foreign-procedure-invocation requirement is reported from the changelog. We were not able to locate the enforcing call site in source, so treat it as a lead rather than a confirmed behaviour.
+:::
+
+---
+
+## (React) Send hooks relay through `sendPrivateOutputNote`
+
+`useSend`, `useTransaction`, and `useMultiSend` now relay private note output via `sendPrivateOutputNote`, following the `notes.sendPrivate` change above. If you wrapped these hooks, re-check the relay path.
+
+---
+
+## (React) `useChainAnchor` and `usePreview`
+
+Both are new in `0.16.0-rc.3`, and `useTransaction().execute` accepts an `anchor` alongside them. `usePreview` is the first summary surface in the React SDK — before it, verifying and co-signing a multisig proposal meant dropping to the WASM client.
+
+If you build a multi-party signing flow, preview and execute against the `anchoredRequest` that `useChainAnchor` returns rather than the request you passed in. Re-resolving a request factory produces a different transaction, and any builder that creates an output note draws a fresh serial number, so the anchor would pin a request nobody executes. See [Transaction Changes](./transaction-changes#collecting-signatures-across-clients-requires-a-chainanchor).
+
+---
+
+## (CLI) `send` renamed to `transfer`
+
+### Summary
+
+The `send` subcommand is now `transfer`. Nothing else changed — every flag, short form, and default is identical. `send` is **not** kept as an alias, so existing scripts fail with an unknown-subcommand error.
+
+### Affected Code
+
+```bash
+# Before (0.15)
+miden-client send -s -t -a 100:: -n private
+
+# After (0.16)
+miden-client transfer -s -t -a 100:: -n private
+```
+
+### Migration Steps
+
+Replace `miden-client send` with `miden-client transfer` in scripts, aliases, and CI jobs. Change nothing else.
+
+---
+
+## (CLI) `account --with-code` replaced by `account --inspect`
+
+### Summary
+
+`--with-code`, which dumped the account code as one pretty-printed blob, is gone. `account --inspect [:]` lists the procedures an account exposes, split into resolved procedures (name, signature, originating package) and unresolved ones listed by MAST root.
+
+### Affected Code
+
+```bash
+# Before (0.15)
+miden-client account --show --with-code
+
+# After (0.16)
+miden-client account --inspect # list procedures
+miden-client account --inspect --verbose # with MASM disassembly
+miden-client account --inspect :receive_asset # a single procedure
+miden-client account --inspect -p ./component.masp # resolve names from extra packages
+```
+
+### Migration Steps
+
+1. Replace `account --show --with-code` with `account --inspect --verbose`.
+2. `--inspect` is mutually exclusive with `--list`, `--show`, and `--default`.
+3. `--package` and `--verbose` both require `--inspect`.
+4. Expect `` entries for procedures whose package the CLI cannot find; pass `--package` to resolve them.
+
+---
+
+## (CLI) `call` counts arguments in field elements
+
+### Summary
+
+`call` validates argument count against the procedure's signature. In 0.15 it compared against the number of *parameters*; in 0.16 it compares against the total stack width in *field elements*. A procedure taking one `Word` now needs four `--args` values.
+
+This change is not in the changelog.
+
+### Affected Code
+
+```bash
+# A procedure with signature `set_item(Word) -> ()`
+
+# Before (0.15): one parameter, one argument
+miden-client call :set_item -p component.masp --args 0x1234
+
+# After (0.16): a Word is four felts wide
+miden-client call :set_item -p component.masp --args
+```
+
+### Migration Steps
+
+1. Re-check every scripted `call` whose procedure takes or returns anything wider than one field element.
+2. Expand each wide argument into one value per field element, in signature order.
+3. Read the `Raw Signature:` line the command prints — it is now the authoritative stack layout.
+
+A mismatched count fails with a clear error rather than executing with a mis-shaped stack, so this one fails loudly.
+
+---
+
+## (CLI) `token_symbol_map.toml`: `id` renamed to `address`
+
+### Summary
+
+The per-symbol entry key changed from `id` to `address`. The value format is unchanged — it was already a bech32 address — so this is a pure key rename. A file still using `id` fails to parse rather than falling back.
+
+### Affected Code
+
+```toml
+# Before (0.15)
+BTC = { id = "mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj", decimals = 8 }
+
+# After (0.16)
+BTC = { address = "mlcl1qru2e5yvx40ndgqqqzusrryr0ucyd0uj", decimals = 8 }
+```
+
+### Migration Steps
+
+1. Rename `id =` to `address =` on every entry. Leave the values alone.
+2. The file lives in the `.miden` directory alongside `miden-client.toml`. If you have both a local and a global `.miden` directory, update both.
+
+---
+
+## (CLI) `init` writes a different package set
+
+### Summary
+
+`init` now writes nine bundled `.masp` component packages instead of seven.
+
+```text
+# Added in 0.16
+basic-non-fungible-faucet.masp
+auth/guarded-multisig-auth.masp
+auth/network-account-auth.masp
+
+# Removed in 0.16
+auth/acl-auth.masp
+```
+
+The removal is the CLI-side consequence of dropping `AuthSingleSigAcl`, and it is the one most likely to break an existing setup. The changelog mentions only the additions.
+
+Two error-reporting changes also landed: running `init` where a config already exists now names the configured network and points at `clear-config`, and an unparseable `--remote-prover-endpoint` is a hard error instead of being silently discarded.
+
+---
+
+## (CLI) Other changes
+
+- **`--debug` and `MIDEN_DEBUG` removed.** Passing `--debug` is now a usage error; setting `MIDEN_DEBUG` is silently ignored.
+- **The pre-confirmation transaction summary shows absolute values, not deltas** — including a column rename and `Nonce incremented by: N` becoming `New account nonce: N`. This follows from the `AccountPatch` move but changes what users read before approving a transaction.
+- **`swap` gained `--payback-note-type `**, defaulting to `private` (0.15 hardcoded private). Note that `pswap` already had this flag in 0.15 with the same default. The tag the command tells you to track also changed, from a swap-specific tag to an account-target tag derived from the sender's account ID.
+- **`consume-notes` gained `--start-debug-adapter ` and `--record `; `exec` gained `--record`.** `exec --start-debug-adapter` already existed in 0.15. Both require a build with the `dap` feature, which is not enabled by default.
+
+---
+
+## Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `Migration error: Attempt to migrate a database with a migration number that is too high` | Pre-0.16 store | Delete and recreate the store. |
+| `error: unrecognized subcommand 'send'` | Renamed | Use `transfer`. |
+| `error: unexpected argument '--with-code'` | Removed | Use `--inspect`. |
+| `Procedure '' expects 4 value(s), got 1` | Arguments counted in field elements | Expand wide arguments. |
+| `missing field 'address'` parsing the token map | Key renamed | Rename `id` to `address`. |
+| `error: unexpected argument '--debug'` | Removed | Delete the flag. |
+| `no method named account_delta` on a transaction result | Renamed | Use `account_patch()`. |
+| Node rejects a submission | Mixed client and node versions | Upgrade both to 0.16. |
+| A component package is missing after `init` | `auth/acl-auth.masp` was removed | Migrate off `AuthSingleSigAcl`. |
diff --git a/versioned_docs/version-0.16/builder/migration/08-masm-changes.md b/versioned_docs/version-0.16/builder/migration/08-masm-changes.md
new file mode 100644
index 00000000..e525ed04
--- /dev/null
+++ b/versioned_docs/version-0.16/builder/migration/08-masm-changes.md
@@ -0,0 +1,395 @@
+---
+sidebar_position: 8
+title: "MASM Changes"
+description: "The new mod declarations, rewritten import syntax, removal of the debug decorators, and the reorganised protocol procedure surface"
+---
+
+# MASM Changes
+
+:::warning Breaking Change
+Miden Assembly gained an explicit module tree. A `.masm` file is no longer picked up because it sits in the right directory — its parent must declare it with `mod` or `pub mod`, and **an undeclared file is silently dropped from the artifact** rather than silently included. The `use` form was split into module imports and braced item imports, alias syntax changed from `->` to `as`, and the `debug.*` and `trace` decorators were removed. On the protocol side, asset helpers, note creation, and several account procedures moved to new paths.
+:::
+
+## Quick Fix
+
+```masm
+# Before (0.15)
+use miden::standards::wallets::basic->basic_wallet
+pub use miden::core::stark::verifier
+
+# After (0.16)
+use miden::standards::wallets::basic as basic_wallet
+pub mod verifier
+pub use {verify} from self::verifier
+```
+
+```masm
+# Every directory of .masm files now needs a mod.masm declaring its children
+pub mod account
+pub mod asset
+mod callbacks # private to the parent
+```
+
+If you encounter errors, continue reading for detailed migration steps.
+
+---
+
+## Summary
+
+The largest change is structural rather than syntactic. In 0.15 the assembler discovered modules by walking directories; in 0.16 it follows an explicit tree of `mod` declarations rooted at your project's root module. This is why the assembler's directory-based entry points disappeared (see [VM & Assembler Changes](./vm-assembler)) and why `miden-project.toml` now requires an explicit `path` to that root.
+
+The failure mode is worth internalising: forgetting a `mod` declaration is **not** an error at the declaration site. The module simply is not part of the artifact, and you discover it later as an undefined-symbol error at the call site — or, worse, not at all if nothing calls it.
+
+Everything else on this page is mechanical: import rewrites, decorator replacements, and renamed protocol procedures.
+
+---
+
+## Every module must be declared with `mod` / `pub mod`
+
+### Summary
+
+A submodule's source is resolved as either `/.masm` or `//mod.masm`, relative to the declaring module's directory. The assembler includes only modules reachable through these declarations ([#3220](https://github.com/0xMiden/miden-vm/pull/3220)).
+
+### Affected Code
+
+In 0.15 the protocol's kernel root module was a comment; the directory tree was walked implicitly. In 0.16 it enumerates its children, and every intermediate directory gained its own `mod.masm` — the protocol repo went from 9 `mod.masm` files to 35:
+
+```masm
+# After (0.16) — kernels/transaction-core/src/mod.masm
+pub mod account
+pub mod account_update
+pub mod asset
+pub mod asset_vault
+mod callbacks # private: not reachable from outside this module
+pub mod constants
+pub mod epilogue
+# … one line per child module
+```
+
+Declarations may be interleaved with `use` statements and appear anywhere among the top-level forms. Two other top-level forms landed alongside `mod`: an optional `namespace ` declaration that names the module explicitly, and `extern package "@"`.
+
+```masm
+# After (0.16) — the full top-level form vocabulary
+namespace app::accounts
+extern package "miden/base@0.1.0"
+mod internal
+pub mod api
+```
+
+### Migration Steps
+
+1. For every directory of `.masm` files, add a `mod.masm` (or a sibling `.masm`) that declares each child with `pub mod `. Use plain `mod ` for modules that should not be reachable from outside the parent.
+2. Walk your project root downward and confirm every `.masm` file is reachable from the root through a chain of declarations.
+3. Do not declare a submodule with the same name as its parent, and do not declare the same source file from two different parents — both are hard errors.
+
+### Common Errors
+
+| Error Message | Cause | Solution |
+| --- | --- | --- |
+| `invalid submodule declaration '': could not find module sources at '/.masm' or '//mod.masm'` | `mod