The plugin is alpha — this guide covers the operational gaps the README
doesn't go into. Read alongside plugin-threat-model.md
before going live.
The auto-generated wallet at ~/.ario-mlflow/wallet.json is fine for
local development and proofs-of-concept. For anything else, generate a
dedicated wallet per environment and treat it like any other production
secret.
A typical setup:
| Environment | Wallet | Set via |
|---|---|---|
| Local dev | Auto-generated, default path | (no env var) |
| CI / dev | Shared dev wallet, low balance | ARIO_MLFLOW_ARWEAVE_WALLET=/secrets/dev-wallet.json |
| Staging | Dedicated staging wallet | ARIO_MLFLOW_ARWEAVE_WALLET=/secrets/staging-wallet.json |
| Production | Dedicated prod wallet, monitored | ARIO_MLFLOW_ARWEAVE_WALLET=/secrets/prod-wallet.json |
Mixing environments on one wallet means a dev mistake can pollute prod's on-chain history. Per-environment wallets isolate that.
Newly generated wallets are Solana (ed25519), written as a Solana CLI
id.json at the wallet path. Arweave RSA wallets remain fully supported;
the plugin detects the chain from the key's JSON shape (an RSA JWK object →
Arweave; a 64-int id.json array or base58 string → Solana). Ops consequences:
- A wallet file already on disk is reused as-is and never overwritten — a
pre-Solana RSA
wallet.jsonkeeps its address, no migration needed. - To pin a chain, point
ARIO_MLFLOW_ARWEAVE_WALLETat that chain's key. - A malformed caller-supplied wallet raises
WalletLoadError(for either chain) — the plugin never silently substitutes a different identity. - The anchor destination is Arweave (via Turbo) regardless of funding chain; the env var keeps its legacy name for backward compatibility.
ARIO_MLFLOW_ARWEAVE_WALLET is a file path, not the key contents (the file
holds a Solana id.json / base58 secret or an Arweave RSA JWK). Wire your
secrets manager to materialize the file at runtime:
HashiCorp Vault (Vault Agent sidecar pattern):
template {
source = "/etc/vault-templates/wallet.json.tmpl"
destination = "/secrets/wallet.json"
perms = 0400
}Set ARIO_MLFLOW_ARWEAVE_WALLET=/secrets/wallet.json in the workload.
AWS Secrets Manager (init container pattern in K8s):
initContainers:
- name: fetch-wallet
image: amazon/aws-cli
command: ["sh", "-c"]
args:
- aws secretsmanager get-secret-value --secret-id arweave/prod-wallet
--query SecretString --output text > /secrets/wallet.json &&
chmod 0400 /secrets/wallet.json
volumeMounts:
- name: wallet
mountPath: /secretsSame env var pointing at the materialized file.
Kubernetes Secret (simplest, less rotation control):
Mount a Secret containing the wallet key (Solana id.json or RSA JWK) as a
file, point the env var at it.
The plugin doesn't enforce file permissions on caller-supplied wallets
(it does set 0o600 on auto-generated ones). Set permissions explicitly
to 0400 on the materialized file, and ensure the workload's user owns
it.
Ed25519 signing keys are independent of the funding wallet. Rotate them separately:
- Funding-wallet rotation (Solana or Arweave): generate a new wallet
key, fund it if needed, update the secret, restart workloads. New uploads
are funded by the new wallet address; existing proofs remain valid
(verification depends on the envelope's embedded Ed25519
public_key, never on the funding wallet). - Ed25519 signing key rotation: similar — new key takes over for new proofs; old proofs remain valid against the public key embedded in their envelope. Document the rotation in your security log so auditors don't see the address change as suspicious.
After any rotation, publish the new public key through your trusted-identity channel (signed announcement, key transparency, etc.) so verifiers can bind the new key to your organization.
The wallet needs to live somewhere CI can read it. Patterns:
- CI-scoped secret (most common): store the wallet key as a CI secret,
write to a temp file at job start, set
ARIO_MLFLOW_ARWEAVE_WALLETto that path. Cleanup happens automatically when the runner is torn down. - OIDC-to-cloud-secrets (cleaner): use GitHub OIDC / GitLab JWT to assume an IAM role, fetch from your secrets manager, materialize as above. No long-lived wallet in CI config.
- Dedicated CI wallet (operational simplicity): one wallet for all CI runs, low balance, monitored. If compromised, rotate without touching prod.
Don't share the same wallet across CI and production training. Even if both are signing real proofs, mixing the chain on one wallet makes forensics harder.
VerifiedModel.predict() writes to the wallet on every prediction
(asynchronously). Workloads need either:
- Wallet file mounted into every replica (K8s Secret, Vault sidecar)
- Or the wallet key materialized to a file from an env var / secret — but
file-based (
ARIO_MLFLOW_ARWEAVE_WALLETas a path) is the supported path
If you scale a service horizontally, every replica signs independently
with the same wallet. That's fine for chain semantics (predictions
chain to the model version's ario.registration_tx, not to each
other) and avoids cross-replica coordination.
If the model's deployed files are watched by the sister
ar-io-agent daemon, pair
VerifiedModel with a VerifyStatusClient so the agent's verdict gates
load and (optionally) every predict(). The gate is load-time only by
default; pass recheck_per_predict=True (and recheck_max_cache_age=
for the contract §9.2 hot-path cache) to extend it into the predict path.
Operational shape:
- Co-locate the model server and the agent on the same host — the
agent's
/v1/verify-status/<asset_id>endpoint is loopback-only (binds127.0.0.1:9847by design). There is no cross-host proxy form. - Provision the management secret as a file mount from the agent's
<state-dir>/management-secret, or read it fromARIO_AGENT_MANAGEMENT_SECRETif your deployment plumbs it in via env. - Choose
on_failuredeliberately."fail_closed"is the regulatory default — a tamper / missing / stale verdict refuses inference."fail_open"proceeds but logs a structured WARN withextra["ario_verify_status"]includingphase(load/predict),asset_id,outcome,stale,policy_hash,current_tx_id. Route that logger to your SIEM; a silent bypass is a regulatory liability. - License gating. Plans without block enforcement receive a
503→VerifyStatusLicenseError(carriesupgrade_url). The agent emits the 503 based on api-guard entitlement state, fail-open when that state is absent — an unlicensed or offline api-guard never bricks the gate.
Full failure-mode matrix in docs/verified-model.md.
The plugin doesn't ship monitoring hooks. Wire your own:
| Signal | Where | Why it matters |
|---|---|---|
ario.verify_status: signed (not anchored) on training runs |
MLflow tag query | Anchoring fell back to local-sign; Arweave upload failed (gateway down, balance issue, network) |
IntegrityError in inference logs |
App logs | T1 — model artifact swap detected; page security ops |
WalletLoadError at service startup |
App logs | T6 — wallet path misconfigured; service can't sign |
last_error populated on ArweaveAnchor instance |
Programmatic check | Failure cause for None returns; useful for dashboards |
| Anchor latency p99 spike | OTel metrics around anchor() calls |
Gateway slowdown |
ario.attestation_level < expected for anchored proofs |
Tag query, scheduled job | ar.io Verify maturity stalled — proof might be propagating slowly |
| Wallet balance below threshold | External cron against Turbo balance API | Avoid running out of credit if you exceed free tier |
Anything in ario.* tags is queryable via MLflow's standard tag-search
API. A scheduled job that runs mlflow.search_runs(filter_string="tags.ario.verify_status = 'signed'")
catches uploads that should have anchored but didn't.
A useful dashboard panel set:
- Anchor success rate (anchored / total) over time
- Median + p99 latency for
anchor() - Count of
IntegrityErrorevents (should be zero) - Distribution of
attestation_levelfor the last 24h of proofs (should trend up to 3 over time) - Wallet balance trend
- Check
last_erroron a freshArweaveAnchorinstance — single source of truth for the cause. - Check Turbo gateway status:
https://turbo.ardrive.io/tx/<recent-tx>/status. - Check wallet balance — if you exceed Turbo's free tier and the wallet is empty, every upload will reject.
- Check network egress to
turbo-gateway.comand your fallback gateways. - Re-run a known-good training job; if it anchors, the issue was transient. If not, escalate.
Failed anchoring degrades to signed-only — your MLflow runs still
succeed, you just don't have an Arweave TX. Re-anchor by re-running
training with the same artifacts (the proof envelope will have a new
event_id and signed_at, but the same payload_hash).
Proof Found = FAIL: the TX isn't on the gateway you're querying. TryARIO_MLFLOW_GATEWAYS=arweave.net,turbo-gateway.comto force a different fetch path.Record Matches = FAIL: either MLflow tampering (the canonical payload's bytes don't hash to the envelope'spayload_hash), or the live re-derivation surface drifted. Compareario/payload.jsondirectly against the envelope's hash by hand to localize.Signature Confirmed = FAIL: the public key in the envelope doesn't match — possibly an envelope was rewritten in transit (impossible on Arweave's content-addressing) or a malicious gateway returned forged bytes. Re-fetch from a different gateway.
They didn't. Each envelope embeds the public key it was signed with; verification uses that key, not the current one. If verification fails post-rotation, it's not the rotation — re-investigate via the runbook above.
- Dedicated wallet per environment, materialized from a secrets manager
-
ARIO_MLFLOW_SIGNING_KEYset explicitly (don't rely on auto-generated key) -
ARIO_MLFLOW_ARIO_VERIFY_URLconfigured (otherwise the attestation row never runs) -
ARIO_MLFLOW_GATEWAYSconfigured with at least two operators you've verified - Monitoring on
verify_status = signedandIntegrityError - Wallet balance alerting (if you expect to exceed free tier)
- Public key published through your trusted-identity channel
- Threat model reviewed by your security team — see
plugin-threat-model.md - Disaster-recovery plan if the wallet file is lost (procedure to rotate to a new wallet, communicate the new public key)
- Backup of
ario/payload.jsonartifacts independent of MLflow (so verification still works if MLflow's artifact store is lost)