AAC developer documentation
On this page

AAC Sidecar public developer-beta starter guide

AAC Sidecar runs beside your agent to verify and carry delegated authority between workloads. It checks signed AAC chains, workload identity and replay protection, then delivers authenticated requests to your agent. Your agent keeps its business policy and returns a supported decision.

Choose a container image for Docker/Kubernetes or a standalone binary for a VM or macOS. Download the configuration template and fill it with your tenant's provisioned identities, credentials and endpoints.

For a complete native AAC business example, run the public AAC: two tenants, one reservation made. It uses the general aac-cli configuration workflow: Vantis Equity delegates $8,000 of a simulated $10,000 travel approval to Tourfedia, which returns a signed receipt for a reservation. The example application creates a reservation result; supplier integration and payment are omitted for clarity. The 30-minute limit bounds authority, not a price hold. The demo checks asynchronous completion, the expected receipt signer and chain root, local/receiver refusals, and certificate refresh/renewal.

What AAC is for

AAC lets a network of AI agents — tens, hundreds or thousands of them, inside one organisation and across organisations — carry delegated authority from agent to agent, with provable identity, local verification and auditable receipts.

Authority starts with a decision your own application makes. Your application authenticates the person, or obtains the approved service identity, and decides what work it may authorize under your policy. AAC binds that decision into a signed chain every later agent can check. In one chain:

  1. A person authorizes work. The originating agent's sidecar mints the root token: a root block carrying your tenant id, a hash of the identity your application supplied, a hash of the originating workload's identity, the root key id, the signature algorithm and the issue time — signed with your tenant's root signing key. The sidecar then appends the first caveat, the class of action.
  2. Each agent that passes the work on attenuates the token. It appends a caveat that cannot widen what may be done, can only shorten the expiry, and names the holder allowed to act next. Its sidecar signs a proof of possession with its identity key, showing that the workload presenting the certificate really holds the private key behind it.
  3. The receiving sidecar verifies locally: the root signature against the originating tenant's published root public key, then the caveat chain and its conditions, then the presenter's proof of possession, its identity against the caveat that named it, and the replay check.
  4. The last agent — the one with nobody left to call — finishes the work and signs a receipt that can be checked afterwards.

Two properties follow.

Authority never widens as it travels. A later hop's limits may stay the same or become tighter; no operation produces a child token permitting more than its parent. Holder binding is a separate control: each hop also names who may act next, so the same chain does not become usable by a different agent.

Every chain expires. No chain outlives 24 hours from the moment its root was signed. Work that runs longer takes a fresh authorization at a business checkpoint rather than a longer chain.

What local verification buys, and what it does not

Each hop is checked with local computation against trust material the sidecar already holds: the root signature, the certificate chain, the proof of possession and the chain's own conditions. Nothing on the request path asks the earlier agents, or their identity providers, to re-authorize the hop — the person signs in once, at the start, and the chain carries that decision onward. That is what keeps authority affordable as a network grows: a chain crossing ten organisations makes no fan-out of token-introspection calls to ten identity providers.

Three limits are worth knowing before you design around it:

What a receipt proves

A receipt is your tenant's signed statement that an agent reported a particular outcome. It is not by itself proof that the real-world action happened, and checking one months later is not automatic: it needs the trust material published at the time, an observation time you trust independently, and your own workflow records. Keys and certificates says what to keep; Verify terminal evidence offline has the procedure.

Before you start

For your first setup, budget about an hour of hands-on work; AAC assigns your trust domain, so there is no DNS propagation wait unless you choose to bring a domain of your own. The 5–10 minute Quick path applies once your tenant configuration, credentials and agent are ready. Start with the path you need:

The commands use a Bash-compatible shell. The AAC Python packages require Python 3.10 or later; python3 below must refer to a supported interpreter. New to AAC terminology? Start with the workflow overview and glossary.

Path Have these ready
Register a developer tenant A GitHub or Google account; Python 3.10+ with venv; a protected directory or secret manager for credentials. AAC assigns your trust domain, so no DNS record is required
Run the local Python example The registered tenant and trust domain; Python environment with the companion libraries; OpenSSL 3.x; an installed sidecar; free local ports 8000, 8080 and 9443. The development PKI recipe below supplies the certificates.
Run the container Docker and your running agent container, plus prepared configuration/key and writable state directories
Install a standalone binary ORAS and Cosign for the signed bundle; choose the archive matching Linux/macOS and AMD64/ARM64
Verify a container or audit artifacts — optional Cosign and Docker Buildx for image verification; ORAS for the optional bundle; Python and Go for the optional deep audit
Deploy beyond the local example Your managed PKI, chosen replay profile, qualified retained A2A storage, and restricted HTTPS ingress; size and test these for your environment

Artifact verification and deep audit are optional user paths, not prerequisites to use AAC. The standalone-bundle path still needs its listed download tools. No Docker Hub account is needed for public pulls. Keep your machine's clock synchronized and its public CA certificates installed. You do not need an existing corporate PKI for the development example. Its short-lived test CA must stay separate from production identities and trust stores.

New tenant: register, create development PKI, publish public trust, then run the local example. Existing tenant: use the Quick guide below, or go directly to audit and diagnostics. Non-Python agent authors can start with the pairing protocol.

Quick installation and run guide

For a 5–10 minute Docker install with your tenant configuration and paired agent ready, follow the public quick installation and run guide. It starts with docker pull cascadeauth/aac-sidecar:v0.4.4, identifies the required configuration and starts the container using the version tag. The detailed verification and deployment instructions below are the advanced path. Tenant onboarding and credential provisioning come before either path.

The full path is: onboard and publish trust, configure/install, run the optional example, then verify artifacts deeply if needed. The tables below are reference material; use the Quick guide above when your tenant configuration and agent are already ready.

Installation artifacts inventory

Choose one sidecar installation format: the container for Docker/Kubernetes, or a standalone binary for a VM, systemd host or macOS. The remaining artifacts serve tenant administration, public-trust publication and workload integration. They do not all need to be installed on every workload host. For the trust-anchor publisher, choose one deployment option as well: its Docker container or Python package. Both run the same daemon. Run one writer for your tenant's root keys and one for each active domain binding, reusing an existing publisher where available: a tenant with a single trust domain needs one process, and a tenant that keeps its assigned domain and adds its own runs two, with root-key publishing enabled in exactly one of them.

The package pages and downloads below are public. Installing these artifacts does not require a GitHub, Docker Hub or PyPI account. Registering and managing your AAC tenant does require the sign-in described later in this guide.

Artifact Package or registry location Type When to install
AAC CLI aac-cli on PyPI Command-line administration tool Used by the tenant operator for this guide's onboarding, credentials, public trust and trace commands. The running sidecar does not depend on the CLI.
AAC Sidecar container (default) docker.io/cascadeauth/aac-sidecar Ready-made Linux container image Choose this for Docker/Kubernetes. This and the standalone binary below are alternative installations of the same sidecar.
Trust-anchor publisher Docker container ghcr.io/cascadeauth/aac-trust-anchor-publisher Containerized public-trust publisher Choose this for a container host or orchestrator to publish/manage the tenant's public root keys and SPIFFE CA bundle. Docker or the orchestrator manages its lifecycle.
Trust-anchor publisher Python package (alternative) aac-trust-anchor-publisher on PyPI Python wheel that installs the publisher daemon command Choose this for installation in a host/VM's Python virtual environment; use systemd on a managed Linux host where applicable. It requires Python, unlike the sidecar's compiled standalone binary.
AAC Sidecar standalone bundle (alternative) oras pull --output ./aac-sidecar-bundle docker.io/cascadeauth/aac-sidecar:v0.4.4-bundle Download containing standalone binaries, guide/template and audit evidence Choose this if you are not using the container. Install ORAS, then follow the standalone installation steps. Container users can download the template directly from this site.
Invoke authentication aac-invoke-auth on PyPI; optional [fastapi] extra Framework-independent Python signing/verification library, with an optional FastAPI/Starlette adapter Install it in a Python workload that uses these helpers. Use [fastapi] for the supplied middleware/dependency integration or this guide's Python example. Other stacks need compatible pairing authentication; they do not need to install this Python package.

Image verification does not require the standalone bundle. The container signature is attached to its registry image and can be checked directly with Cosign, followed by a pull of the verified digest. The bundle adds standalone binaries and the SPDX/provenance/OCI files used by the optional deep audit. ORAS is a command-line tool for downloading files stored in a container registry. The command above saves the bundle's files in ./aac-sidecar-bundle; use a new, empty directory. Use oras pull for these files and docker pull for the runnable sidecar image. ORAS is not needed to run the sidecar.

Installing current releases

Use the current release of each AAC artifact. The sidecar installation commands below use the current published version. The image and standalone bundle use the same version. Exact versions and digests are available in the release record.

The sidecar has no latest tag, so an untagged pull will fail and every sidecar command here names the version. Its companion packages are different: install the current release of each from PyPI without a version pin, and let pip resolve it —

Upgrade an existing companion installation before following this guide. Pin a version only when your own deployment needs a fixed one.

For the advanced verification path, reject a tag that does not resolve to a SHA-256 digest or an artifact whose signature or digest fails.

Downloading, copying, installing, or using the sidecar accepts the included AAC Sidecar Developer Beta Binary License 1.0. The three Python companion artifacts remain independently licensed under Apache-2.0. The bundle and image also carry THIRD_PARTY_NOTICES.md for modules linked into the compiled binary.

How a delegated workflow works

Originator application
  |  mint-root: choose authority and submit a task
  v
Sidecar A --authenticated /invoke--> Agent A
  |  Agent A returns forward(destination, narrower predicates)
  |  signed chain + proof of the presenting workload
  v
Sidecar B --authenticated /invoke--> Agent B
  |  Agent B returns settle or refuse
  v
settle -> signed settlement evidence
refuse -> refusal result (no terminal attestation)

Sidecar B checks authority, identity, recipient and replay protection before calling Agent B. Each agent still decides whether the requested business action is appropriate. A forward decision can narrow authority, never broaden it. The local example uses one sidecar twice; the two-agent example shows the same flow with separate processes. Optional central telemetry correlates selected metadata; full local evidence stays with the tenant.

Term Meaning and configuration
Tenant The organization/project boundary assigned a tnt-<uuid> ID; tenant.id
Workload / agent Your running application, identified by one concrete SPIFFE ID; agent.spiffe_id
Sidecar The process beside a workload that verifies and carries authority
Root-signing key Starts an authority chain; tenant.signing_key_file or a configured remote signer
SVID An X.509 certificate binding a workload SPIFFE identity to its public key; agent.svid_cert_file
DPoP Proof signed by the presenting workload, binding authority to the exact HTTP request; uses the local SVID key
Class of action A named initial authority profile selected at mint time; classes_of_action
Predicate A restriction such as action, amount_max, task_ref or expiry
Holder audience The workload identity permitted to hold a chain step; class and attenuation audience constraints
Destination A named peer URL, exact recipient identity and limits; destinations
Attenuation Creating a child authority step with equal or narrower permissions
Continuation authority A short-lived right to continue a verified inbound task for the same pair/task/presenter
Settle / terminal attestation An agent finishes a task and its sidecar signs the terminal evidence using a distinct key
Pairing secret Authenticates calls between one sidecar and its paired agent; sidecar.agent_invoke_auth.secret_file
Replay protection Rejects repeated presenting proofs; replay_protection
A2A retry state Retains dispatch outcomes so the same dispatch ID/body can be retried without executing twice; a2a.egress_idempotency

AAC stage endpoints

Use https://api.stage.cascadeauth.dev for both CLI admin and data-plane requests. Public trust documents are served from https://trust.stage.cascadeauth.dev. These are AAC stage endpoints, not a production service or an instruction to expose your sidecar's loopback port.

Check HTTPS reachability without credentials or tenant creation:

curl --fail --silent --show-error https://api.stage.cascadeauth.dev/healthz

The response includes status: ok and control_plane_version. This is a liveness check, not proof that your tenant, trust material, or sidecar is ready.

Supported deployments and endpoint allowlist

Surface Supported beta profile
Container Linux amd64/arm64; ready-made distroless image; UID/GID 65532
Standalone Linux amd64/arm64 and macOS amd64/arm64; non-root process
Agent pairing Same network namespace; local /invoke and /a2a/v1 authenticated with the per-pair secret
Native A2A A2A 1.0 unary SendMessage; JSON-RPC 2.0; no streaming or general-purpose A2A method support
Root and terminal signing File-backed Ed25519/P-256, or explicit Azure Key Vault Standard software-protected non-exportable P-256 keys
Workload DPoP Local file-backed key matching the SVID; remote DPoP is unsupported
Replay Basic: explicit memory/basic, process-local and lost on restart. Shared durable: qualified authenticated-TLS Valkey ha-retained-write-safe; no fallback
A2A retry state Retained private bbolt file, one process owner; storage must be qualified for your deployment
Service level Developer evaluation/integration beta; no production SLA or production-rate claim

The sidecar is provider-neutral. AWS/GCP/HSM/PKCS#11 signer adapters, general plugin loading, and tenant-built sidecar images are outside this beta profile. Azure hosting is optional; use the qualification worksheet below if relevant.

Allow only the endpoints your selected installation actually needs:

Caller Destination Purpose and boundary
Installer Docker Hub registry/auth/content endpoints for docker.io/cascadeauth/aac-sidecar Public image and bundle download; no AAC artifact credential
Installer pypi.org, files.pythonhosted.org Public Python companion/sample dependencies
Publisher container installer ghcr.io/cascadeauth/aac-trust-anchor-publisher and GHCR's content endpoints Optional public publisher image
Advanced verifier Sigstore's public verification/transparency services as required by Cosign Validate release identity and transparency evidence
CLI, sidecar projection/telemetry, publisher https://api.stage.cascadeauth.dev:443 Admin/data APIs, STS, signed trust ingest; use the appropriate credential role
Sidecar, publisher public reads https://trust.stage.cascadeauth.dev:443 Public root-key and SPIFFE-bundle polling
CLI/browser Selected GitHub/Google sign-in endpoints and CLI's temporary local callback Interactive identity-provider sign-in; no blanket IdP access needed by the sidecar
Paired agent/client 127.0.0.1:8080; sidecar to 127.0.0.1:8000 Trusted local APIs/callbacks; do not expose outside the shared namespace
Peer sidecars Explicit configured peer HTTPS endpoints, normally port 9443 TLS, AAC chain, DPoP and recipient verification; URLs must be final, without redirects
Sidecar, if Shared durable selected Tenant-local authenticated-TLS Valkey endpoint Shared retained replay claims; no fallback to Basic or central service
Optional Azure signer Exact tenant vault HTTPS hostname and platform managed-identity endpoint Managed identity plus pinned key-version get/sign; no client secret or file fallback

Registry/CDN and identity-provider redirects are operated by those providers; apply their current endpoint policy to installer/browser hosts. They are not a reason to allow general internet egress from the running sidecar. Your DNS, time synchronization and PKI distribution must also work. The local sample uses only loopback peer endpoints and the explicit stage trust/data hosts.

Configuration and workflow state

Keep business progress and reports in your application's storage. Messages waiting for other branches of a workflow are buffered temporarily and can be lost when the sidecar restarts. Replay protection and retry-result storage do not replace an application database.

For work spanning days or weeks, the tenant application must retain its own business evidence and progress, then obtain independently authorized fresh chains at checkpoints. It may explicitly reuse a task_ref for correlation. Stored evidence does not renew expired authority; AAC does not supply a workflow database or automatic long-running orchestration.

You can configure request timeouts in your sidecar YAML when an agent or peer needs more or less time to respond:

Tenant setting Use it to
timeouts.agent_invoke_timeout_seconds Limit how long the sidecar waits for your local agent
timeouts.cross_org_dispatch_timeout_seconds Set the default timeout for a request to another sidecar
destinations.<name>.timeout_ms Override that default for one destination, in milliseconds

Use positive, unquoted numbers. A destination override takes precedence over the dispatch default. For A2A, keep it within the configured overall operation deadline. Leave these settings at their defaults unless your application needs an adjustment.

Set authority duration with valid_for on the class or destination. Business dates in the payload do not extend that authority or the request timeout.

Authority, predicates and A2A integration

Native originators call POST /v1/agent/mint-root or /v1/agent/delegations on the sidecar's external TLS listener. Both aliases require the pair's AAC1-HMAC-SHA256 signature, including in dev mode. Sign every chain-start request. Receiving or forwarding applications are not automatically chain-start callers. The example client below signs its native request with the published helper.

The body contains human_originator, configured class_of_action, optional task_ref, optional object payload, and optional obligations, for example:

{"predicate":"amount_max","value":"10000"}

Place those predicate/value objects in the obligations array. The application validates the human's login and decides policy, then supplies its result. T0 contains root/identity metadata; T1 carries the first business predicates. Omitted/empty obligations preserve Mode 0 using class predicates. Equal duplicate values combine once, differing values within the list or across class/request are refused, and disjoint predicates combine. There is no override or automatic ceiling intersection: a dynamic amount normally has one source, the request.

Names must be in the registry below. Values are nonempty strings without comma or colon; enforced amount_max, amount_min and valid_from also accept JSON integers and normalize to canonical decimal strings. Booleans, fractions, malformed integer text and out-of-range values refuse before mint. Keep predicates compact; put full business reports in your application's storage. applied_predicates reports the accepted limits: static values retain their existing JSON types, obligation values are strings, and generated valid_until is an integer; audience is a separate field.

A top-level request valid_until or obligation named valid_until always refuses, even when equal or shorter. Remove it and use the class's configured valid_for. Payload dates remain business data.

Sign uppercase POST, the exact alias used, timestamp, exact transmitted raw body and covered X-AAC headers using the pairing protocol below. Serialize once and send those bytes; a signature made for the other alias fails. Each auth header appears exactly once; duplicate covered headers also fail. Send a compact JSON request with the correct content type. Oversized or malformed requests are rejected without creating authority. Missing pairing configuration is 503 ERR_CONFIG_ERROR; failed pairing is 401 ERR_PAIRING_AUTH_FAILED; invalid native predicates, conflicts or reserved expiry are 422 ERR_INVALID_MINT_INPUT. An invalid issuer retains its 422 ERR_INVALID_OIDC_ISSUER code; ordinary schema errors remain ordinary 422. Selected invalid class predicates are configuration errors (503).

Freshness is an inclusive 30-second window, not idempotency or one-time chain creation. A valid replay can execute mint again; do not automatically retry an uncertain result. Pure mint returns 200/not_attempted; callback delivery failure returns 207/failed with already-committed chain provenance. The response does not turn synthetic human claims into authentication.

If configuration/obligations encode task_ref, omitted/null request metadata inherits it, matching metadata succeeds and disagreement refuses before mint. Otherwise requested/generated metadata remains metadata and is not silently added to T1. References used in headers are 1–256 printable ASCII characters. For signed correlation or convergence, normally supply task_ref as a per-request obligation. A static class value deliberately makes every chain of that class share a convergence key; it is not a default for independent runs. Only a signed predicate can key convergent arrival storage. Metadata-only correlation is not chain-authenticated, and task_ref is not an idempotency key.

On native forwarding, explicit destination, additional and composite-attestation task_ref predicates must agree with nonempty workflow correlation. Every branch is checked before any branch signs or sends. A conflicting receive callback returns 502 ERR_INVALID_AGENT_DECISION; proactive composite returns 400; a mint callback returns 207/failed because its root is already committed. Prior buffered arrivals survive refusal; the failed receive's current arrival is removed. Metadata is never silently signed. If incoming correlation is absent, an explicitly signed new reference supplies the outgoing header and local audit.

An explicitly authorized separate originator may hold the pair secret only inside the same trusted tenant-application boundary. It gains callback-signing capability too; this is not a mint-only credential. Never share across pairs or tenants. Keep appropriate network/ingress restrictions; the local sample binds the entire TLS listener to 127.0.0.1. Mode B and a new credential system are not part of this release.

Agent /invoke responses use AgentDecision: forward supplies a configured destination, payload and optional narrowing additional_predicates; settle supplies a settlement ID and action summary; refuse supplies a reason. The sidecar validates the decision, delegates to the destination's exact workload identity, and signs/verifies the applicable chain and terminal evidence. Additional predicates narrow existing authority. The example carries the same task_ref, keeps action: dev_noop, and shortens validity from ten to five minutes; it never grants a broader audience or business permission.

The beta's canonical predicate names are:

account, action, amount, amount_max, amount_min, assessed_damage_amount,
assessment_outcome, beneficiary, beneficiary_account, beneficiary_class,
claim_ref, composite_conflict_minerals_clear, composite_esg_scope3_co2e_kg_total,
composite_payout_amount, composite_payout_total, composite_units_total,
conflict_minerals_clear, currency, data_scope, esg_scope3_co2e_kg, hours,
human_authorization_class, max_authority_amount, min_account_age_days,
originator_reference, payout_amount, program_reference, purpose, quantity_units,
reporting_quarter, scope, surveyor_findings, task_ref, unit_price_usd,
valid_from, valid_until

Use nonempty scalar values without comma or colon (reserved encoding separators). amount_max, amount_min, valid_from and valid_until use nonnegative decimal integers; time values are Unix seconds. A later amount cap cannot increase, an amount floor or not-before time cannot decrease, and the effective expiry is the minimum expiry in the chain. External chains require an expiry and have a maximum 24-hour root-relative lifetime. The agent must still enforce its business meaning for the other registered fields; a recognized name is not a general business-policy engine. Unknown predicate names fail closed.

For unary A2A, the paired agent signs POST /v1/agent/a2a/dispatch using the published invoke-auth API. The envelope requires schema_version, a UUID dispatch_id, named destination_profile, task_ref, authority, additional_predicates, and a2a_request. The demonstrated authority mode is originate; continue is reserved for verified inbound authority belonging to the same pair/task/presenter and its retention window. Never derive continued authority from caller-supplied identity strings. The external sidecar verifies AAC/DPoP before forwarding the supported A2A body to the authenticated local handler. The handler receives verified context rather than raw bearer/DPoP credentials.

A retry must preserve the dispatch ID and exact envelope. Changed content under an existing ID is a conflict. An in-progress or outcome-unknown response is not permission to issue a new ID and repeat a business action: follow the returned status and reconcile with your operation before retrying. Retain the bbolt file through process/container replacement for the configured retention window. Expiry is a bounded guarantee, not permanent deduplication.

Optional Azure qualification worksheet

Complete this before relying on the Azure adapter or a particular storage class. The local example does not supply these results. Keep identifiers and sanitized receipts in your own tenant record; never send keys to AAC.

Check Record and pass condition
Exact package Image/bundle digest, version, signature identity and guide checksum
Root/terminal keys Separate, exact versioned HTTPS key URIs; Standard software-protected P-256; public keys match configured identity/certificates
Managed identity Selected system/user-assigned identity and least-privilege key get/sign; local workload DPoP stays local
Failures Disabled key, removed grant, auth failure, throttling, timeout, malformed/wrong-key response: no minted artifact and no fallback
Persistent storage Provider/SKU/class/mount options; private ownership; remount and replacement retain bbolt; missing/corrupt/insecure/locked files fail closed
Capacity Allocate storage for your expected traffic and retention period, including saved responses. Confirm records survive replacement and that capacity limits produce a clear, recoverable failure.
A2A requests Check that your normal request sizes and response times fit your configured limits. Confirm retries do not repeat a completed business action.
Connectivity Public trust polling, exact workload projection, central metadata delivery and local terminal evidence
Lifecycle Credential rotation/revocation, upgrade, rollback, rejected-beta handling, local cleanup and retained-state custody
Cost/support Your expected cloud costs, responsible operator and escalation contact

When using signers, configure each purpose with provider: azure-key-vault and an exact versioned key_uri; omit that purpose's file-backed private-key setting. Keep the terminal certificate and workload SVID/key. Set AZURE_CLIENT_ID only when selecting a user-assigned managed identity. Unsupported providers or conflicting file/provider settings fail startup.

Diagnostics and support

Work in this order: exact installed version → configuration/file permissions → /healthz and /readyz → trust publication and certificate validity → workload projection → pairing → workflow outcome → local and central evidence. A 401 at an agent callback means pairing failed before business execution. A trust or recipient rejection should leave the agent untouched. A healthy process with missing trust can remain unable to accept an authenticated workflow.

Contact support@cascadeauth.com with the version, image/bundle digest, platform, timestamp/time zone, request/root identifier if appropriate, stable error code and sanitized reproduction. Beta support is best-effort without a 24/7 SLA. For a suspected compromise, stop affected admission, preserve local evidence and contact the same support address; your operator owns credential revocation and recovery. License questions go to legal@cascadeauth.com.

Advanced installation guide — container

1. Verify and pull the image

Install Docker and Cosign. Verify the published digest against CascadeAuth's GitHub Actions keyless identity:

set -euo pipefail
export AAC_SIDECAR_VERSION=v0.4.4
export AAC_SIDECAR_IMAGE=docker.io/cascadeauth/aac-sidecar
export AAC_SIDECAR_DIGEST="$(
  docker buildx imagetools inspect "${AAC_SIDECAR_IMAGE}:${AAC_SIDECAR_VERSION}" |
    awk '$1 == "Digest:" { print $2; exit }'
)"
[[ "${AAC_SIDECAR_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]

cosign verify \
  --certificate-identity 'https://github.com/CascadeAuth/aac-sidecar-go/.github/workflows/release.yml@refs/heads/main' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "${AAC_SIDECAR_IMAGE}@${AAC_SIDECAR_DIGEST}"

docker pull "${AAC_SIDECAR_IMAGE}@${AAC_SIDECAR_DIGEST}"
docker image inspect "${AAC_SIDECAR_IMAGE}@${AAC_SIDECAR_DIGEST}" \
  --format '{{index .Config.Labels "org.opencontainers.image.version"}} {{index .Config.Labels "org.opencontainers.image.licenses"}}'

The final line must print:

v0.4.4 LicenseRef-AAC-Sidecar-Developer-Beta-1.0

2. Place the sidecar beside the workload

The image already contains the compiled /aac-sidecar entrypoint and runs as non-root 65532:65532. Do not copy the binary into a tenant-built image.

The production security boundary expects the sidecar and its paired agent to share one network namespace. In Kubernetes, put both containers in the same Pod. The agent listens on 127.0.0.1:8000; the sidecar keeps sidecar.agent_invoke_url: http://127.0.0.1:8000/invoke and its loopback API on 127.0.0.1:8080. Expose only the sidecar external port 9443 through the tenant's approved Service/ingress path.

Mount rather than bake:

The starter selects Basic replay protection. Choose Shared durable when replay history must survive restart or coordinate replicas; provision the qualified Valkey service and workload credentials first. Both profiles require the normal identity, trust, TLS and pairing setup. See replay profiles.

A minimal Pod fragment is:

apiVersion: v1
kind: Pod
metadata:
  name: paired-agent
spec:
  securityContext:
    runAsNonRoot: true
    fsGroup: 65532
  containers:
    - name: agent
      image: <tenant-agent-image-by-digest>
      env:
        - name: AAC_INVOKE_AUTH_SECRET_FILE
          value: /etc/aac/invoke-auth/invoke-auth.secret
      volumeMounts:
        - name: invoke-auth
          mountPath: /etc/aac/invoke-auth
          readOnly: true
    - name: aac-sidecar
      # Paste the verified AAC_SIDECAR_DIGEST from step 1 after `@`.
      image: docker.io/cascadeauth/aac-sidecar@sha256:REPLACE_WITH_VERIFIED_DIGEST
      args: ["-config", "/etc/aac/sidecar-config.yaml"]
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      ports:
        - name: aac-external
          containerPort: 9443
      volumeMounts:
        - name: sidecar-config
          mountPath: /etc/aac
          readOnly: true
        - name: invoke-auth
          mountPath: /etc/aac/invoke-auth
          readOnly: true
        - name: aac-state
          mountPath: /var/lib/aac
  volumes:
    - name: sidecar-config
      secret:
        secretName: aac-sidecar-config
        defaultMode: 0400
    - name: invoke-auth
      secret:
        secretName: aac-invoke-auth
        defaultMode: 0400
    - name: aac-state
      persistentVolumeClaim:
        claimName: aac-sidecar-state

Set your tenant's TLS, trust, replay, signer, resource and ingress settings before deploying. Download sidecar-config.template.yaml, fill every required path and identifier, and verify replay, trust and an authenticated workflow before admitting traffic.

Plain Docker may use --network=container:<agent-container> to share the agent's loopback namespace. Ordinary Docker Compose containers have separate loopback interfaces; widening the sidecar loopback bind is a development-only posture and requires dev_mode: true, so it is not the production recipe.

3. Verify process identity and readiness

docker exec aac-sidecar /aac-sidecar -version
curl --fail --silent http://127.0.0.1:8080/healthz
curl --fail --silent http://127.0.0.1:8080/readyz

Run the two HTTP checks from the shared Pod/network namespace. Do not expose the loopback port. Liveness is not readiness: keep admission closed unless /readyz confirms replay-authority readiness, then independently verify trust material and an authenticated workflow.

Companion developer tools

Install the AAC CLI on the operator's machine in a dedicated virtual environment:

python3 -m venv .aac-tools
. .aac-tools/bin/activate
python -m pip install --upgrade aac-cli

aac --version
aac profile --help
aac tenant register --help
aac tenant api-key --help
aac trust-anchor --help

If this host will operate the tenant's publisher using Python, install it here. Skip this wheel install if you use its container deployment or an existing tenant-operated publisher:

python -m pip install --upgrade aac-trust-anchor-publisher
aac-trust-anchor-publisher --help

Install the following in the Python agent's environment for this guide's optional FastAPI example or your own FastAPI/Starlette integration. It can use the same environment for the local example:

python -m pip install --upgrade 'aac-invoke-auth[fastapi]'
python -c 'import aac_invoke_auth; print(aac_invoke_auth.__file__)'

aac-invoke-auth is a library, not a separate daemon. Its base package supplies framework-independent Python helpers; another Python framework can use those without the [fastapi] extra. Other workload stacks must provide equivalent pairing authentication before trusting sidecar requests. Installing this Python package is conditional; authenticating the paired calls is not.

For deployments with separate ingest and public trust hosts, set AAC_TAP_SPIFFE_BUNDLE_READ_URL to the full public SPIFFE bundle URL supplied for the trust domain. For stage that is https://trust.stage.cascadeauth.dev/.well-known/spiffe-bundle/<trust-domain>. Signed uploads continue to use the supplied API-host ingest URL.

Create a local stage profile after installing the CLI:

aac profile create stage \
  --admin-url https://api.stage.cascadeauth.dev \
  --data-plane-url https://api.stage.cascadeauth.dev

This writes local profile settings only; it does not register a tenant. If a profile named stage already exists, inspect it before changing it. Continue with developer self-service below, or use your existing enterprise tenant and approved credential process. Never paste API keys, recovery keys, signing keys, pairing secrets, private payloads, or complete configuration into support messages.

Register your developer tenant

Choose a new, unbound profile for each tenant. The following example uses the stage profile created above and GitHub sign-in; use --idp google for Google. Your verified identity becomes the first tenant administrator.

Generate a tenant-admin key in a new private directory. The publisher needs this key to sign ingest requests; it is distinct from the workload's root key. Do not rerun key generation over an existing key.

set -euo pipefail
export AAC_PROFILE=stage
export AAC_ONBOARDING_DIR="$HOME/aac-onboarding/$AAC_PROFILE"
umask 077
mkdir -p "$AAC_ONBOARDING_DIR"
test ! -e "$AAC_ONBOARDING_DIR/tenant-admin.pem"
openssl genpkey -algorithm ed25519 -out "$AAC_ONBOARDING_DIR/tenant-admin.pem"
openssl pkey -in "$AAC_ONBOARDING_DIR/tenant-admin.pem" \
  -pubout -out "$AAC_ONBOARDING_DIR/tenant-admin.pub.pem"

aac tenant register --profile "$AAC_PROFILE" \
  --display-name 'YOUR TEAM OR PROJECT' --contact 'YOUR EMAIL' \
  --tenant-admin-pubkey-file "$AAC_ONBOARDING_DIR/tenant-admin.pub.pem" \
  --idp github --output table

aac sso login --profile "$AAC_PROFILE"
aac sso whoami --profile "$AAC_PROFILE" --output table
export AAC_TENANT_ID="$(aac profile show "$AAC_PROFILE" | python -c 'import json,sys; print(json.load(sys.stdin)["binding"]["tenant_id"])')"
aac tenant describe --profile "$AAC_PROFILE" --tenant-id "$AAC_TENANT_ID" --output table

Follow the CLI's browser/device instructions. AAC assigns the tnt-<uuid> identifier; there is no user-chosen --tenant-id on registration. The CLI shows the API-key value once, stores it with mode 0600 under ~/.aac/credentials/<tenant-id>, and binds the profile. Save a protected copy in your secret manager. A bound profile refuses a second registration; create a different profile for a different tenant. If a registration response is interrupted, use the same profile and follow the CLI's resume instruction; do not create a competing registration to recover the response.

Bind your trust domain and register the workload

AAC assigns your tenant a trust domain, so you need no DNS name and no TXT record. Assigning it and registering a workload are separate operations; run them in this order:

AAC_TRUST_DOMAIN="$(aac tenant assign-hosted-domain --profile "$AAC_PROFILE" \
  | python -c 'import json,sys; print(json.load(sys.stdin)["trust_domain"])')"
export AAC_TRUST_DOMAIN
echo "$AAC_TRUST_DOMAIN"
aac tenant list-trust-domains --profile "$AAC_PROFILE" --output table
aac tenant add-workload --profile "$AAC_PROFILE" \
  --spiffe-id "spiffe://${AAC_TRUST_DOMAIN}/demo/agent" --display-name 'Synthetic demo agent'
aac tenant list-workloads --profile "$AAC_PROFILE" --output table

The assigned domain is built from your tenant identifier and looks like tnt-<uuid>.tenants.stage.cascadeauth.dev. Registration itself usually assigns it already and prints it, so the command above normally reads that same domain back; it is idempotent and safe to rerun. If a previous hosted binding was revoked, reactivate it with aac tenant reactivate-hosted-domain before registering a workload. Workload registration records the identity only; your tenant's PKI/SPIFFE system still issues its certificate and matching private key.

Use a DNS domain of your own instead

Optional, and only if your tenant must be identified by its own name, such as agents.example.com. It needs a DNS name you control and a published TXT record. Use your actual canonical lowercase DNS name:

export AAC_TRUST_DOMAIN=agents.example.com
aac tenant issue-domain-challenge --profile "$AAC_PROFILE" --domain "$AAC_TRUST_DOMAIN"

Publish the exact TXT name/value returned by that command at your DNS provider, wait for propagation, then bind it and register the workload under it. Run these lines rather than the block above, whose first line would replace AAC_TRUST_DOMAIN with the assigned domain:

aac tenant verify-domain --profile "$AAC_PROFILE" --domain "$AAC_TRUST_DOMAIN"
aac tenant bind-trust-domain --profile "$AAC_PROFILE" --trust-domain "$AAC_TRUST_DOMAIN"
aac tenant add-workload --profile "$AAC_PROFILE" \
  --spiffe-id "spiffe://${AAC_TRUST_DOMAIN}/demo/agent" --display-name 'Synthetic demo agent'
aac tenant list-workloads --profile "$AAC_PROFILE" --output table

Issuing a new challenge replaces the prior challenge; retain the current TXT record while the binding needs domain evidence. Existing or previously revoked bindings follow their lifecycle rules, so an ownership/history rejection needs operator resolution. Do not retry it with a bootstrap token or invent a tenant ID.

Keep credential roles separate

Every key, certificate and shared secret this guide creates is described in one place: Keys and certificates. That page says what each item proves, where it lives, who gets a copy and how long it lasts, and it covers both ways an agent gets its certificates — a development authority created for you, or your own issuer's. Read it before you generate anything.

Two rules the rest of this section depends on. Use Ed25519 or P-256 keys and currently valid, matching SPIFFE certificates; your CA bundle must validate the workload and terminal certificates for the bound domain. And keep pairing secrets distinct from every signing key: for a managed deployment, generate one in a new protected location with umask 077; openssl rand -hex 32 > invoke-auth.secret, then install it privately in both processes. The development recipe below generates its own.

Generate development PKI

Use this only for the local development example after registering your tenant and spiffe://<trust-domain>/demo/agent workload. Keep the shell variables from onboarding. OpenSSL 3.x creates a seven-day development CA and one-day workload, terminal and localhost TLS certificates, all with distinct keys. Your existing root-signing key is created separately in the trust-publication step.

The recipe refuses an existing output directory. It never adds the CA to your system trust store. Run it from the Python environment used for the CLI. It uses Python's enumerable default CA certificates, falling back to certifi's public roots (installed with the CLI) if that store is empty. If your network requires additional private CA certificates, add those trusted PEM certificates to outbound-ca.pem explicitly; do not disable TLS verification.

If saving the block below as a script, first set AAC_DEMO_DIR in the calling shell, for example export AAC_DEMO_DIR="$HOME/aac-demo/$AAC_PROFILE". Variables exported inside a child script do not persist in its parent shell.

set -euo pipefail
umask 077
: "${AAC_PROFILE:?Set the profile used for onboarding}"
: "${AAC_TRUST_DOMAIN:?Set your assigned or verified trust domain}"
export AAC_DEMO_DIR="${AAC_DEMO_DIR:-$HOME/aac-demo/$AAC_PROFILE}"
export AAC_DEMO_SPIFFE_ID="spiffe://${AAC_TRUST_DOMAIN}/demo/agent"
python - <<'PY'
import os, re, ssl
import certifi
domain = os.environ['AAC_TRUST_DOMAIN']
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', domain):
    raise SystemExit('Use your canonical lowercase trust domain')
roots = ssl.create_default_context().get_ca_certs(binary_form=True)
if not roots:
    roots = ssl.create_default_context(cafile=certifi.where()).get_ca_certs(binary_form=True)
if not roots:
    raise SystemExit('No public CA roots; repair the CLI environment/certifi installation')
PY
test ! -e "$AAC_DEMO_DIR"
mkdir -p "$(dirname "$AAC_DEMO_DIR")"
mkdir "$AAC_DEMO_DIR"
mkdir "$AAC_DEMO_DIR/pki" "$AAC_DEMO_DIR/state" "$AAC_DEMO_DIR/publish-ca"
cd "$AAC_DEMO_DIR/pki"
openssl genpkey -algorithm ed25519 -out dev-ca.key
openssl req -new -x509 -key dev-ca.key -out dev-ca.crt -days 7 \
  -subj '/CN=AAC local development CA' \
  -addext 'basicConstraints=critical,CA:TRUE,pathlen:0' \
  -addext 'keyUsage=critical,keyCertSign,cRLSign' \
  -addext 'subjectKeyIdentifier=hash'
cat > identity.ext <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth,serverAuth
subjectAltName=critical,URI:${AAC_DEMO_SPIFFE_ID}
EOF
for purpose in workload terminal; do
  openssl genpkey -algorithm ed25519 -out "$purpose.key"
  openssl req -new -key "$purpose.key" -out "$purpose.csr" -subj '/'
  openssl x509 -req -in "$purpose.csr" -CA dev-ca.crt -CAkey dev-ca.key \
    -set_serial "0x$(openssl rand -hex 16)" -days 1 \
    -extfile identity.ext -out "$purpose.crt"
done
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out server.key
openssl req -new -key server.key -out server.csr -subj '/CN=localhost'
cat > server.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=IP:127.0.0.1,DNS:localhost
EOF
openssl x509 -req -in server.csr -CA dev-ca.crt -CAkey dev-ca.key \
  -set_serial "0x$(openssl rand -hex 16)" -days 1 -extfile server.ext -out server.crt
openssl verify -CAfile dev-ca.crt workload.crt terminal.crt server.crt
openssl rand -hex 32 > pairing.secret
cp dev-ca.crt "$AAC_DEMO_DIR/publish-ca/local-demo.ca.pem"
python - <<'PY'
from pathlib import Path
import ssl
import certifi
roots = ssl.create_default_context().get_ca_certs(binary_form=True)
if not roots:
    roots = ssl.create_default_context(cafile=certifi.where()).get_ca_certs(binary_form=True)
if not roots:
    raise SystemExit('No public CA roots; repair the CLI environment/certifi installation')
public_roots = ''.join(ssl.DER_cert_to_PEM_cert(cert) for cert in roots)
Path('outbound-ca.pem').write_text(Path('dev-ca.crt').read_text() + public_roots)
PY
chmod 600 ./*
cd "$AAC_DEMO_DIR"

Publish only publish-ca/local-demo.ca.pem using the SPIFFE publication instructions below. Keep pki/dev-ca.key with the operator; mount only the workload, terminal and TLS keys into the sidecar. The peer trusts the published CA; the paired application receives only pairing.secret.

Check expiry before each later run with openssl x509 -in "$AAC_DEMO_DIR/pki/workload.crt" -noout -dates. Reissue matching leaf certificates before they expire, using the same tenant-approved issuer, or create a new development directory and deliberately update your trust/config. Do not overwrite keys or remove old trust while a workload still uses it. The OpenSSL certificate request and certificate signing references describe the commands used here.

Publish public trust material

For a file-backed demonstration, create a root-signing key and public half in your private onboarding directory, then copy only the public half into the publisher's root-key directory:

export AAC_ROOT_KEY_ID=demo-root-v1
test ! -e "$AAC_ONBOARDING_DIR/${AAC_ROOT_KEY_ID}.pem"
openssl genpkey -algorithm ed25519 -out "$AAC_ONBOARDING_DIR/${AAC_ROOT_KEY_ID}.pem"
openssl pkey -in "$AAC_ONBOARDING_DIR/${AAC_ROOT_KEY_ID}.pem" \
  -pubout -out "$AAC_ONBOARDING_DIR/${AAC_ROOT_KEY_ID}.pub.pem"
mkdir -p "$AAC_ONBOARDING_DIR/root-public"
cp "$AAC_ONBOARDING_DIR/${AAC_ROOT_KEY_ID}.pub.pem" "$AAC_ONBOARDING_DIR/root-public/"

export AAC_TAP_TENANT_ID="$AAC_TENANT_ID"
export AAC_TAP_ADMIN_KEY_FILE="$AAC_ONBOARDING_DIR/tenant-admin.pem"
export AAC_TAP_ROOT_KEYS_DIR="$AAC_ONBOARDING_DIR/root-public"
export AAC_TAP_ROOT_KEYS_INGEST_URL=https://api.stage.cascadeauth.dev/v1/root-keys/ingest
export AAC_TAP_POLL_INTERVAL_SECONDS=60
aac-trust-anchor-publisher

This runs the installed publisher in the foreground. Run one active writer for your tenant's root keys and one per active domain binding, and never two competing writers for the same root set or binding, to avoid uncoordinated ingest sequence changes. The public filename stem is the root key ID; configure tenant.key_id: demo-root-v1 and mount the private half only into the workload. Rotation gets a new key ID. Successfully ingested public material remains stored after the publisher exits; the serving cache TTL is not a key-expiry timer. Run the publisher again when keys or CA bundles change, following their rotation/revocation lifecycle.

To enable SPIFFE-bundle publication, stop the foreground publisher, place public CA certificates only in a separate directory using filenames <anchor_id>.ca.pem, and add these settings in the same shell before restarting that one publisher. The trust-domain binding must already be active.

export AAC_TAP_SPIFFE_BUNDLE_DIR="$AAC_ONBOARDING_DIR/spiffe-ca-public"
# Populate this directory with your issuer's public <anchor_id>.ca.pem files.
export AAC_TAP_SPIFFE_TRUST_DOMAIN="$AAC_TRUST_DOMAIN"
export AAC_TAP_SPIFFE_BUNDLE_INGEST_URL=https://api.stage.cascadeauth.dev/v1/spiffe-bundle/ingest
export AAC_TAP_SPIFFE_BUNDLE_READ_URL="https://trust.stage.cascadeauth.dev/.well-known/spiffe-bundle/${AAC_TRUST_DOMAIN}"
aac-trust-anchor-publisher

For the development PKI above, set AAC_TAP_SPIFFE_BUNDLE_DIR to "$AAC_DEMO_DIR/publish-ca" instead. That directory contains only the public development CA certificate. Never point the publisher at pki/.

Check the public bundle at the configured read URL and root keys at https://trust.stage.cascadeauth.dev/.well-known/aac-root-keys/<tenant-id>. Do not send a CA private key or workload private key to the publisher.

In another terminal, confirm ingest and public key visibility:

aac trust-anchor ingest-history --profile "$AAC_PROFILE" --output table
aac trust-anchor list --profile "$AAC_PROFILE" --output table

Only proceed to the runnable example when the expected root key and SPIFFE CA bundle are visible, your workload projection is registered, and all configured key/certificate pairs are valid. A successful registration by itself does not complete this preparation.

Optional central trace forwarding

The sidecar always follows its configured local telemetry sink. To also send eligible chain metadata to AAC, add this block using the supplied data-plane URL and a private tenant API-key file:

sidecar:
  telemetry:
    sink: "/var/lib/aac/telemetry.jsonl"
    central_forward:
      control_plane_url: "https://api.stage.cascadeauth.dev"
      api_key_file: "/etc/aac/keys/tenant-api-key"

The parent directory must exist. The sidecar exchanges that API key for a short-lived telemetry-ingest session and sends only the allowed central metadata. Business payloads, task references, human claims, raw AAC/DPoP credentials and terminal attestations remain local. A2A protocol diagnostics without chain identifiers also remain local. Local sink: "none" may be used with central forwarding when local retention is intentionally disabled.

Forwarding uses a bounded background queue and never waits on the authorization path. Outages, queue saturation or shutdown can lose central events; retain local audit data according to your needs. Coarse local warnings identify exchange/forward failures without printing credentials or response bodies. The API-key file is reread on each token exchange, so an atomic replacement takes effect without a restart; already-issued sessions retain their own expiry.

After a real workflow, use aac chain --help and the returned root token identifier to inspect its central trace. Allow for asynchronous delivery; successful local events alone do not prove central delivery.

Optional runnable paired-agent example

Use this synthetic example with a prepared test tenant and the sidecar configuration below. It performs no payment, trade, or other business action. Its human-originator fields are explicitly synthetic input, not proof of a GitHub, Google, or other identity-provider sign-in. A real originator must take those fields from its authenticated application context.

This example uses a small Python/FastAPI application. If you already have an agent, integrate pairing authentication into its existing handler and server.

Install the optional sample application server

In the same virtual environment used for the companion tools:

python -m pip install --upgrade uvicorn httpx PyYAML

Uvicorn serves the example FastAPI application over local HTTP; HTTPX sends the example client requests. Install these only when running this Python example. PyYAML writes the complete example configuration below.

Replay protection profiles

The template selects Basic: backend: memory, deployment_profile: basic. It works with sidecar.dev_mode: false and does not relax pairing, loopback, trust, projection, signatures, proof time or presenter/recipient checks. Existing configurations are not silently converted. Bare memory without a profile remains development-only and is reported as development-memory.

Profile Replay history and requirements
Basic Bounded process-local memory; one atomic claim per running history. Restart loses history; replicas do not coordinate
Shared durable backend: valkey, deployment_profile: ha-retained-write-safe; existing qualified retained-write-safe authority, authenticated TLS and workload-scoped credentials. Retained claims coordinate replicas of the exact receiver SPIFFE identity

Shared durable failures never fall back to Basic. Choose one of the supported profiles above; neither changes the developer-beta license or support terms.

Basic keeps replay records until they expire; it does not discard them to make room for new traffic. Duplicate proofs return 403 ERR_DPOP_REPLAY. Full storage returns 503 ERR_REPLAY_AUTHORITY_SATURATED; reduce admitted traffic or wait for records to expire. An unavailable replay store returns 503 ERR_REPLAY_AUTHORITY_UNAVAILABLE. Check /readyz for the selected replay_backend and replay_profile when diagnosing the deployment.

For Basic, replay_protection.memory_max_entries controls capacity. Size it for your expected concurrent replay records, including traffic bursts and requests that later fail authorization. A capacity refusal means you should reduce admitted traffic or increase capacity within your host's available memory.

A still-valid proof can pass replay checking again after a Basic restart or on another replica. Choose Shared durable when replay history must survive replacement or coordinate replicas. Your application must still prevent repeat business actions; a fresh proof does not make an operation safe to repeat. Keep clocks synchronized.

Operator-led cutover to Shared durable

  1. Close protected admission to every Basic instance for the receiving workload. Drain in-flight operations, stop those instances, and prevent any old instance from rejoining. Record the final drain time.
  2. Configure every replacement for the same qualified Shared durable workload namespace and its scoped credentials. Preserve retained shared records and other workloads' state.
  3. Hold admission closed for at least 120 full seconds after the last Basic instance drains. Verify synchronized/non-regressing clocks and that the latest possible Basic proof expiry has passed at every replacement verifier. Extend the hold for clock uncertainty; measure the interval independently of wall-clock jumps.
  4. Complete shared readiness/quarantine checks and an authenticated validation workflow, then reopen admission only to Shared durable instances.

A fresh Valkey epoch supplies the hold only if its entire interval is verified to follow the last Basic drain. An already initialized epoch can be ready immediately; restarting a sidecar does not reset it. Readiness alone never replaces the hold. Do not delete/reset shared state to manufacture a timer. There is no automatic migration engine, Basic startup quarantine, seamless cutover or automatic downgrade.

Create the complete local configuration

Use the development PKI above, your registered tenant/workload and the root key published in the trust-publication step. Keep AAC_TENANT_ID, AAC_TRUST_DOMAIN, AAC_ONBOARDING_DIR and AAC_DEMO_DIR in your shell. The CLI stores the bare API-key string at ~/.aac/credentials/<tenant-id>; its .session sibling is different and must not be used here. For a key kept elsewhere, set AAC_API_KEY_FILE to the protected file containing just that key.

Save this as configure_demo.py in your development directory. It writes an entire sidecar-config.yaml; no template overlay or manual merging is needed. Existing configurations are never overwritten. All listeners stay on localhost. This local example selects Basic replay explicitly; its small retained A2A-state limits are example sizing. Basic selection is independent of development mode, which remains enabled for the local fixture posture.

import os
import re
from pathlib import Path
import yaml

os.umask(0o077)
base = Path(os.environ['AAC_DEMO_DIR']).expanduser().resolve()
onboarding = Path(os.environ['AAC_ONBOARDING_DIR']).expanduser().resolve()
tenant = os.environ['AAC_TENANT_ID']
domain = os.environ['AAC_TRUST_DOMAIN']
if not re.fullmatch(r'tnt-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', tenant):
    raise SystemExit('Use the tenant ID returned by registration')
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', domain):
    raise SystemExit('Use your assigned or verified lowercase trust domain')
spiffe = f'spiffe://{domain}/demo/agent'
key_id = os.environ.get('AAC_ROOT_KEY_ID', 'demo-root-v1')
root_key = onboarding / (key_id + '.pem')
api_key = Path(os.environ.get('AAC_API_KEY_FILE', str(Path.home()/'.aac/credentials'/tenant))).expanduser().resolve()
pki = base/'pki'
required = [root_key, api_key, *[pki/name for name in
    ('workload.key', 'workload.crt', 'terminal.key', 'terminal.crt',
     'server.key', 'server.crt', 'outbound-ca.pem', 'pairing.secret')]]
if any(not p.is_file() or not p.stat().st_size for p in required):
    raise SystemExit('Prepare every credential file before creating the configuration')
if not (base/'state').is_dir():
    raise SystemExit('Run development PKI setup first')
config = {
    'schema_version': '1.0',
    'replay_protection': {'backend': 'memory', 'deployment_profile': 'basic', 'memory_max_entries': 100000},
    'sidecar': {
        'dev_mode': True, 'loopback_bind_address': '127.0.0.1', 'loopback_port': 8080,
        'external_bind_address': '127.0.0.1', 'external_port': 9443,
        'agent_invoke_url': 'http://127.0.0.1:8000/invoke',
        'agent_invoke_auth': {'secret_file': str(pki/'pairing.secret')},
        'tls_cert_file': str(pki/'server.crt'), 'tls_key_file': str(pki/'server.key'),
        'tls_ca_file': str(pki/'outbound-ca.pem'), 'log_level': 'info',
        'telemetry': {'sink': str(base/'state/telemetry.jsonl')},
    },
    'tenant': {'id': tenant, 'key_id': key_id,
               'signing_key_file': str(root_key), 'signing_key_algorithm': 'ed25519'},
    'agent': {'spiffe_id': spiffe, 'svid_key_file': str(pki/'workload.key'),
              'svid_cert_file': str(pki/'workload.crt'),
              'attestation_key_file': str(pki/'terminal.key'),
              'attestation_cert_file': str(pki/'terminal.crt')},
    'trust_anchors': {'source': 'control_plane',
                      'control_plane_url': 'https://trust.stage.cascadeauth.dev',
                      'tenant_ids': [tenant]},
    'spiffe_bundles': {'source': 'control_plane',
                       'control_plane_url': 'https://trust.stage.cascadeauth.dev',
                       'trust_domains': [domain]},
    'workload_projection': {'source': 'control_plane',
                            'control_plane_url': 'https://api.stage.cascadeauth.dev',
                            'api_key_file': str(api_key)},
    'a2a': {'public_base_url': 'https://127.0.0.1:9443',
            'local_handler_url': 'http://127.0.0.1:8000/a2a/v1',
            'max_request_body_bytes': 262144, 'deadline_seconds': 60,
            'max_concurrent_requests': 20, 'max_json_nesting_depth': 32,
            'max_json_nodes': 10000, 'continuation_authority': {'retention_seconds': 600},
            'egress_idempotency': {'state_file': str(base/'state/a2a-egress.db'),
                                   'retention_seconds': 86400, 'max_entries_per_pair': 4096,
                                   'max_cached_response_body_bytes': 4096,
                                   'max_reserved_cached_bytes_per_pair': 16777216}},
    'classes_of_action': {'demo_verify': {'predicates': {'action': 'dev_noop'},
                                         'valid_for': '+10m', 'audience_self': spiffe}},
    'destinations': {name: {'url': 'https://127.0.0.1:9443'+path,
                           'audience_pattern': spiffe, 'predicates': {'action': 'dev_noop'},
                           'valid_for': '+5m', 'timeout_ms': 10000}
                     for name, path in [('self_receive', '/v1/agent/receive'), ('self_a2a', '/a2a/v1')]},
}
with (base/'sidecar-config.yaml').open('x') as stream:
    yaml.safe_dump(config, stream, sort_keys=False)
print('Created complete configuration:', base/'sidecar-config.yaml')
cd "$AAC_DEMO_DIR"
python configure_demo.py

Confirm that your root key and development CA are visible at the public trust URLs before starting. Keep this setup local; a real deployment needs managed certificates, an explicitly chosen replay profile, qualified A2A storage and restricted ingress.

Save and start the agent

Save the following as agent.py. Set AAC_INVOKE_AUTH_SECRET_FILE to the per-pair secret mounted in your agent; the sidecar's sidecar.agent_invoke_auth.secret_file must read the same bytes. Keep the file private and do not disable authentication.

from fastapi import FastAPI, Request
import os
from aac_invoke_auth.fastapi import InvokeAuthGuard, InvokeAuthMiddleware

app = FastAPI()
app.add_middleware(
    InvokeAuthMiddleware,
    guard=InvokeAuthGuard.from_env(),
    protected_paths=("/invoke", "/a2a/v1"),
)

def sample_decision(body):
    payload = body.get("current_arrival", {}).get("payload", {})
    if isinstance(payload, dict) and payload.get("step") == "forward":
        return {"action": "forward", "destination": os.environ.get("AAC_DEMO_DESTINATION", "self_receive"),
                "payload": {"step": "settle"},
                "additional_predicates": {"task_ref": body["task_ref"]}}
    if isinstance(payload, dict) and payload.get("step") == "settle":
        return {"action": "settle", "settlement_id": body["task_ref"],
                "action_summary": "Completed synthetic demonstration; no business effect."}
    return {"action": "refuse", "reason": "Demo agent has no other business policy."}

@app.post("/invoke")
async def invoke(request: Request):
    return sample_decision(await request.json())

@app.post("/a2a/v1")
async def a2a(request: Request):
    body = await request.json()
    # The sidecar admits only the supported unary SendMessage profile here.
    return {"jsonrpc": "2.0", "id": body["id"], "result": {"message": {
        "messageId": body["params"]["message"]["messageId"] + "-reply",
        "contextId": "demo-" + body["params"]["message"]["messageId"],
        "role": "ROLE_AGENT", "parts": [{"text": "Synthetic AAC-authorized reply"}]}}}

Save the client below too, then choose the Docker or standalone start instructions. An unsigned direct POST to /invoke must return 401; the same applies to /a2a/v1. Missing pairing configuration prevents agent startup.

Save and run the client

Save this as demo_client.py. Run it only in the same trusted local network namespace as the sidecar. It sends mint requests to the locally bound TLS listener on port 9443 and A2A dispatch to loopback port 8080.

import json
import os
import ssl
import time
import uuid
from pathlib import Path

import httpx
from aac_invoke_auth import sign_invoke_request


def run_demo(client, secret, originator, destination_profile="self_a2a"):
    task = "demo-" + str(uuid.uuid4())
    human = {"iss": "https://synthetic.invalid", "sub": "demo-only",
             "auth_time_unix_seconds": int(time.time())}
    path = "/v1/agent/mint-root"
    body = json.dumps({"human_originator": human, "class_of_action": "demo_verify",
        "task_ref": task, "payload": {"step": "forward"}}, separators=(",", ":")).encode()
    headers = {"Content-Type": "application/json"}
    headers.update(sign_invoke_request(secret=secret, method="POST", path=path,
                                       headers=headers, body=body))
    response = originator.post(path, headers=headers, content=body)
    response.raise_for_status()
    minted = response.json()
    if minted.get("delivery_status") != "delivered":
        raise RuntimeError("native workflow was not delivered")

    path = "/v1/agent/a2a/dispatch"
    dispatch_id = str(uuid.uuid4())
    envelope = {"schema_version": "aac.a2a.egress.v1",
        "dispatch_id": dispatch_id, "destination_profile": destination_profile,
        "task_ref": task + "-a2a",
        "authority": {"mode": "originate", "class_of_action": "demo_verify",
                      "human_originator": human},
        "additional_predicates": {},
        "a2a_request": {"jsonrpc": "2.0", "id": task, "method": "SendMessage",
            "params": {"message": {"messageId": str(uuid.uuid4()),
                       "role": "ROLE_USER", "parts": [{"text": "Synthetic hello"}]}}}}
    body = json.dumps(envelope, separators=(",", ":")).encode()

    def send():
        headers = {"Content-Type": "application/json",
                   "X-AAC-Envelope-Schema": "aac.a2a.egress.v1"}
        headers.update(sign_invoke_request(secret=secret, method="POST",
                                           path=path, headers=headers, body=body))
        result = client.post(path, headers=headers, content=body)
        result.raise_for_status()
        if "error" in result.json():
            raise RuntimeError("A2A returned a protocol error")
        return result

    first = send()
    retry = send()  # Same dispatch_id AND same envelope; fresh pairing signature.
    if retry.content != first.content:
        raise RuntimeError("identical A2A retry returned different bytes")
    return {"native_delivery": "delivered", "task_ref": task,
            "root_token_id": minted["root_token_id"],
            "a2a_dispatch_id": dispatch_id, "a2a_retry": "same response bytes"}


if __name__ == "__main__":
    secret = Path(os.environ["AAC_INVOKE_AUTH_SECRET_FILE"]).read_bytes().strip()
    tls = ssl.create_default_context(cafile=os.environ["AAC_DEMO_CA_FILE"])
    with httpx.Client(base_url="http://127.0.0.1:8080", timeout=65, trust_env=False) as client:
        with httpx.Client(base_url="https://127.0.0.1:9443", verify=tls, timeout=65, trust_env=False) as originator:
            print(json.dumps(run_demo(client, secret, originator,
                os.environ.get("AAC_DEMO_A2A_DESTINATION", "self_a2a")), indent=2))

Run the example with Docker

This is the default container path; it does not require ORAS or a standalone sidecar. Save agent.py, demo_client.py and the complete configuration above in AAC_DEMO_DIR. Run this staging helper as configure_container.py. It copies only the selected runtime files into a new container/ directory and refuses to overwrite it. The development CA private key stays outside that directory.

import os
from pathlib import Path
import shutil
import yaml

os.umask(0o077)
base=Path(os.environ['AAC_DEMO_DIR']).expanduser().resolve()
config=yaml.safe_load((base/'sidecar-config.yaml').read_text())
for name in ('agent.py','demo_client.py'):
    if not (base/name).is_file():
        raise SystemExit('Save both example applications before staging')
stage=base/'container';stage.mkdir()
for name in ('sidecar','agent','pair','state'):(stage/name).mkdir()
def stage_key(source,name):
    shutil.copyfile(source,stage/'sidecar'/name)
    return '/etc/aac/'+name
config['tenant']['signing_key_file']=stage_key(config['tenant']['signing_key_file'],'root.pem')
for field,name in [('svid_key_file','workload.key'),('svid_cert_file','workload.crt'),
                   ('attestation_key_file','terminal.key'),('attestation_cert_file','terminal.crt')]:
    config['agent'][field]=stage_key(config['agent'][field],name)
for field,name in [('tls_key_file','server.key'),('tls_cert_file','server.crt'),('tls_ca_file','outbound-ca.pem')]:
    config['sidecar'][field]=stage_key(config['sidecar'][field],name)
config['workload_projection']['api_key_file']=stage_key(config['workload_projection']['api_key_file'],'tenant-api-key')
shutil.copyfile(base/'pki/pairing.secret',stage/'pair/pairing.secret')
shutil.copyfile(base/'pki/dev-ca.crt',stage/'pair/dev-ca.crt')
config['sidecar']['agent_invoke_auth']['secret_file']='/run/secrets/pairing.secret'
config['sidecar']['telemetry']['sink']='/var/lib/aac/telemetry.jsonl'
config['a2a']['egress_idempotency']['state_file']='/var/lib/aac/a2a-egress.db'
(stage/'sidecar/sidecar-config.yaml').write_text(yaml.safe_dump(config,sort_keys=False))
for name in ('agent.py','demo_client.py'):shutil.copyfile(base/name,stage/'agent'/name)
(stage/'agent/Dockerfile').write_text('''FROM python:3.12-slim
RUN python -m pip install --no-cache-dir aac-invoke-auth[fastapi] uvicorn httpx
WORKDIR /app
COPY agent.py demo_client.py /app/
RUN chmod 0444 /app/*.py
ENV PYTHONDONTWRITEBYTECODE=1
USER 65532:65532
CMD ["python", "-m", "uvicorn", "agent:app", "--host", "127.0.0.1", "--port", "8000"]
''')
print('Created container configuration and sample application build directory')

Build the sample application image locally and pull the published sidecar:

cd "$AAC_DEMO_DIR"
python configure_container.py
docker build --tag aac-demo-agent "$AAC_DEMO_DIR/container/agent"
docker pull docker.io/cascadeauth/aac-sidecar:v0.4.4

Run from a non-root host account. This local demonstration runs both containers with your numeric UID/GID so their staged private files can remain mode 0600; managed deployments normally provision permissions for the image's default 65532 user. The two containers share one network namespace. No ports are published; the client runs inside that namespace too.

test "$(id -u)" -ne 0
docker run --detach --name aac-demo-agent --user "$(id -u):$(id -g)" \
  --read-only --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
  --mount "type=bind,src=${AAC_DEMO_DIR}/container/pair,dst=/run/secrets,readonly" \
  --env AAC_INVOKE_AUTH_SECRET_FILE=/run/secrets/pairing.secret \
  --env AAC_DEMO_CA_FILE=/run/secrets/dev-ca.crt aac-demo-agent
docker run --detach --name aac-demo-sidecar --user "$(id -u):$(id -g)" \
  --network container:aac-demo-agent --read-only --cap-drop ALL --security-opt no-new-privileges \
  --mount "type=bind,src=${AAC_DEMO_DIR}/container/sidecar,dst=/etc/aac,readonly" \
  --mount "type=bind,src=${AAC_DEMO_DIR}/container/pair,dst=/run/secrets,readonly" \
  --mount "type=bind,src=${AAC_DEMO_DIR}/container/state,dst=/var/lib/aac" \
  docker.io/cascadeauth/aac-sidecar:v0.4.4 -config /etc/aac/sidecar-config.yaml
docker logs --tail 30 aac-demo-agent
docker logs --tail 30 aac-demo-sidecar
docker exec aac-demo-agent python -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:8080/readyz").read().decode())'
docker exec aac-demo-agent python /app/demo_client.py

Allow startup and the initial trust refresh to finish. Readiness checks replay; the authenticated example checks trust and pairing. Read local evidence in container/state/telemetry.jsonl. After the exercise, stop/remove only these two example containers; retain the private staging/state directory according to your test policy:

docker stop aac-demo-sidecar aac-demo-agent
docker rm aac-demo-sidecar aac-demo-agent

Run the example with a standalone sidecar

Use this alternative if you installed the standalone binary. In new terminals, activate the same Python environment and set AAC_DEMO_DIR to the directory created during setup. Start the agent, then the sidecar:

cd "$AAC_DEMO_DIR"
export AAC_INVOKE_AUTH_SECRET_FILE="$AAC_DEMO_DIR/pki/pairing.secret"
python -m uvicorn agent:app --host 127.0.0.1 --port 8000
"$HOME/.local/bin/aac-sidecar" -config "$AAC_DEMO_DIR/sidecar-config.yaml"

From another activated terminal, check readiness and run the client:

cd "$AAC_DEMO_DIR"
export AAC_INVOKE_AUTH_SECRET_FILE="$AAC_DEMO_DIR/pki/pairing.secret"
export AAC_DEMO_CA_FILE="$AAC_DEMO_DIR/pki/outbound-ca.pem"
curl --fail --silent --show-error http://127.0.0.1:8080/readyz
python demo_client.py

The client prints only correlation identifiers and outcomes. The native chain mints a root and initial holder authority, invokes the agent, forwards to self_receive with the same task restriction, then settles with a signed terminal attestation. The separate A2A call returns the sample reply; its identical retry uses the retained result rather than repeating the operation.

A successful HTTP response is only part of the evidence. Match the returned root_token_id in the local telemetry file and check the mint, dispatch, receive and terminal respond events. The terminal attestation stays local; central trace forwarding deliberately excludes it. If forwarding is enabled, use aac chain show --help to inspect the same root's central metadata after allowing for asynchronous delivery. Record sanitized outcomes, never raw credentials, private payloads, SVIDs with private keys or complete logs.

For a cross-tenant test, replace the self destinations with your explicitly registered peer's final URL and exact SPIFFE ID, include both tenants' public root keys and trust domains, and agree on predicates with that peer. Never point this no-policy demo at a real business handler.

Work through two agents

For two independently registered tenants with separate CAs and the CLI-managed lifecycle, use the Compose reservation demo. The standalone example below remains a lower-level same-tenant reference.

Use the standalone installation for this optional multi-process example. It is separate from the Docker path above. This example runs Agent A and Agent B as separate local processes in the same test tenant. It uses your existing development CA and published root; no CA private key is sent to an agent or peer. First stop the single-agent example's processes. Keep its files and retained state.

Register the second workload, then install the helper's local dependencies:

aac tenant add-workload --profile "$AAC_PROFILE" \
  --spiffe-id "spiffe://${AAC_TRUST_DOMAIN}/demo/peer" --display-name 'Synthetic peer agent'
python -m pip install --upgrade cryptography PyYAML

Save the following as configure_peer.py in AAC_DEMO_DIR. It creates fresh, distinct peer keys/certificates and pairing secret, two complete configurations, and separate retained-state paths. It refuses existing peer output. It uses the development CA for at most one-day leaf validity, bounded by CA expiry.

import copy
import datetime as dt
import ipaddress
import os
from pathlib import Path
import secrets
import yaml
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID

os.umask(0o077)
base = Path(os.environ['AAC_DEMO_DIR']).expanduser().resolve()
config = yaml.safe_load((base/'sidecar-config.yaml').read_text())
domain = config['agent']['spiffe_id'].split('/')[2]
peer_spiffe = f'spiffe://{domain}/demo/peer'
ca = x509.load_pem_x509_certificate((base/'pki/dev-ca.crt').read_bytes())
ca_key = serialization.load_pem_private_key((base/'pki/dev-ca.key').read_bytes(), None)
now = dt.datetime.now(dt.timezone.utc)
if not isinstance(ca_key, ed25519.Ed25519PrivateKey) or ca.not_valid_after_utc <= now+dt.timedelta(hours=1):
    raise SystemExit('Use a currently valid CA from the development PKI recipe')
public_bytes=lambda key:key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
if public_bytes(ca_key.public_key()) != public_bytes(ca.public_key()) or ca.not_valid_before_utc > now:
    raise SystemExit('Development CA certificate/key mismatch or CA is not yet valid')
if (base/'sidecar-A.yaml').exists() or (base/'peer').exists():
    raise SystemExit('Peer configuration already exists; inspect it before changing it')
peer = base/'peer'; peer.mkdir()
(peer/'pki').mkdir(); (peer/'state').mkdir()
def issue(name, identity=None):
    key = ed25519.Ed25519PrivateKey.generate() if identity and name == 'workload' else ec.generate_private_key(ec.SECP256R1())
    subject = x509.Name([]) if identity else x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')])
    sans = [x509.UniformResourceIdentifier(identity)] if identity else [x509.IPAddress(ipaddress.ip_address('127.0.0.1')), x509.DNSName('localhost')]
    cert = (x509.CertificateBuilder().subject_name(subject).issuer_name(ca.subject)
        .public_key(key.public_key()).serial_number(x509.random_serial_number())
        .not_valid_before(now-dt.timedelta(seconds=5))
        .not_valid_after(min(now+dt.timedelta(days=1), ca.not_valid_after_utc))
        .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
        .add_extension(x509.KeyUsage(True, False, False, False, False, False, False, False, False), critical=True)
        .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH, ExtendedKeyUsageOID.SERVER_AUTH] if identity else [ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
        .add_extension(x509.SubjectAlternativeName(sans), critical=bool(identity)).sign(ca_key, None))
    (peer/'pki'/f'{name}.key').write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
    (peer/'pki'/f'{name}.crt').write_bytes(cert.public_bytes(serialization.Encoding.PEM))
issue('workload', peer_spiffe); issue('terminal', peer_spiffe); issue('server')
(peer/'pki/pairing.secret').write_text(secrets.token_hex(32)+'\n')
(peer/'pki/outbound-ca.pem').write_bytes((base/'pki/outbound-ca.pem').read_bytes())
(peer/'pki/dev-ca.crt').write_bytes((base/'pki/dev-ca.crt').read_bytes())
sender, receiver = copy.deepcopy(config), copy.deepcopy(config)
sender['sidecar']['telemetry']['sink'] = str(base/'state/telemetry-A.jsonl')
sender['a2a']['egress_idempotency']['state_file'] = str(base/'state/a2a-egress-A.db')
sender['destinations'] = {name: {'url':'https://127.0.0.1:9444'+path,
    'audience_pattern':peer_spiffe, 'predicates':{'action':'dev_noop'},
    'valid_for':'+5m', 'timeout_ms':10000}
    for name,path in [('peer_receive','/v1/agent/receive'),('peer_a2a','/a2a/v1')]}
receiver['tenant'].pop('signing_key_file')
receiver['agent'].update(spiffe_id=peer_spiffe,
    svid_key_file=str(peer/'pki/workload.key'), svid_cert_file=str(peer/'pki/workload.crt'),
    attestation_key_file=str(peer/'pki/terminal.key'), attestation_cert_file=str(peer/'pki/terminal.crt'))
receiver['sidecar'].update(loopback_port=8081, external_port=9444,
    agent_invoke_url='http://127.0.0.1:8001/invoke',
    agent_invoke_auth={'secret_file':str(peer/'pki/pairing.secret')},
    tls_cert_file=str(peer/'pki/server.crt'), tls_key_file=str(peer/'pki/server.key'),
    tls_ca_file=str(peer/'pki/outbound-ca.pem'), telemetry={'sink':str(peer/'state/telemetry-B.jsonl')})
receiver['a2a'].update(public_base_url='https://127.0.0.1:9444', local_handler_url='http://127.0.0.1:8001/a2a/v1')
receiver['a2a']['egress_idempotency']['state_file'] = str(peer/'state/a2a-egress.db')
receiver['classes_of_action'] = {}; receiver['destinations'] = {}
with (base/'sidecar-A.yaml').open('x') as f: yaml.safe_dump(sender, f, sort_keys=False)
with (peer/'sidecar-B.yaml').open('x') as f: yaml.safe_dump(receiver, f, sort_keys=False)
print('Created sidecar-A.yaml and peer/sidecar-B.yaml')
cd "$AAC_DEMO_DIR"
python configure_peer.py

Use four terminals, with the same Python environment active and AAC_DEMO_DIR set to the absolute directory from setup. Run one of these commands in each:

cd "$AAC_DEMO_DIR"
AAC_INVOKE_AUTH_SECRET_FILE="$AAC_DEMO_DIR/pki/pairing.secret" AAC_DEMO_DESTINATION=peer_receive \
  python -m uvicorn agent:app --host 127.0.0.1 --port 8000
cd "$AAC_DEMO_DIR"
AAC_INVOKE_AUTH_SECRET_FILE="$AAC_DEMO_DIR/peer/pki/pairing.secret" \
  python -m uvicorn agent:app --host 127.0.0.1 --port 8001
"$HOME/.local/bin/aac-sidecar" -config "$AAC_DEMO_DIR/sidecar-A.yaml"
"$HOME/.local/bin/aac-sidecar" -config "$AAC_DEMO_DIR/peer/sidecar-B.yaml"

Check readiness at http://127.0.0.1:8080/readyz and http://127.0.0.1:8081/readyz, then run the client in a fifth terminal:

cd "$AAC_DEMO_DIR"
AAC_INVOKE_AUTH_SECRET_FILE="$AAC_DEMO_DIR/pki/pairing.secret" \
AAC_DEMO_CA_FILE="$AAC_DEMO_DIR/pki/outbound-ca.pem" AAC_DEMO_A2A_DESTINATION=peer_a2a \
  python demo_client.py

Agent A forwards the native task to peer_receive; Sidecar B verifies it and Agent B settles. The A2A operation goes to peer_a2a; its repeated dispatch uses the stored response. Correlate state/telemetry-A.jsonl with peer/state/telemetry-B.jsonl and verify B's terminal certificate/attestation using B's registered identity. Stop these four owned processes when finished; retain the state and private material according to your test-tenant policy.

For separate hosts or tenants, replace localhost with restricted HTTPS ingress and use independently provisioned credentials on each side. Each receiver must trust the originator root tenant via trust_anchors.tenant_ids and the presenter CA domain via spiffe_bundles.trust_domains; use authenticated workload projection for the presenting and terminal identities. The sender's destination must name the peer's exact SPIFFE ID. Do not copy a CA private key or another workload's private keys between those hosts.

Audit your workflows

Configure a tenant-local JSONL sink and retain it alongside your application's business audit. Each line is one JSON object; fields not applicable to an event are omitted. Start with root_token_id from the client, then follow token IDs, hop indices, workload identities, decisions and failures across your sidecars. Wall-clock arrival order across hosts is not a causal guarantee.

Event Interpretation
mint Root/initial authority creation or its failure
receive Authority admission and local delivery, or a verification/callback failure
dispatch Outbound delegation/result, including downstream failure
respond Local terminal response; a successful settlement includes the compact JWS
composite_mint A supported composite authority was created from multiple arrivals
a2a_ingress Protocol/body/admission diagnostics for a unary A2A request
a2a_egress Paired-agent A2A dispatch outcome and retained-state pressure

Native events use result: success or failure; A2A diagnostics may use accepted, rejected or failed. Event availability depends on how far the request progressed. A request rejected before authority can be decoded may have no root/token ID. A bad pairing signature at the application is an application HTTP 401, not proof of a chain-verification event at the sidecar.

Local event fields

Fields Meaning
timestamp_unix_seconds, event_type, result Event time as Unix seconds (possibly fractional), category and outcome
root_token_id, token_id, parent_token_id, hop_index Chain correlation and parent/hop relationships
creator_svid_hash, audience_hash, caveat_audience Creator/audience binding and the readable audience where available
tenant_id, actor_spiffe_id, presenter_spiffe_id Local tenant/actor and verified presenting workload
caveat_predicates, destination, task_ref, originator_issuer Local restrictions, destination profile and task/originator context; may contain business-sensitive values
parent_token_ids_list, parent_root_token_ids, parent_tenant_ids, inbound_token_id Multi-parent/composite and inbound correlation
http_status, response_size_bytes, latency_ms Observed response and operation measurements when emitted
terminal_attestation, terminal_attestation_verification Compact terminal JWS and the downstream verifier's recorded result, where available
failure_code, failure_detail, agent_decision_action Stable failure category, diagnostic text (bounded), and agent decision
agent_id, agent_descriptor_hash Optional agent/descriptor correlation
method, protocol_version, result_kind, error_category Optional A2A protocol diagnostics
request_body_bytes, configured_body_limit_bytes Request size and configured A2A body bound
egress_entries, egress_reserved_cached_bytes, egress_cached_response_bytes Current local retained dispatch state and byte use
egress_expired_reclaimed_total, egress_saturation_rejections_total, egress_oversize_rejections_total Cumulative reclamation/capacity/response-bound counters
egress_conflicts_total, egress_in_progress_hits_total, egress_cached_hits_total Cumulative changed-body conflicts, in-progress hits and cached-result reuse

Not every supported field is populated by every current path. Treat optional absence as unknown, not a successful verification or a zero measurement. Ordinary access/application logs are separate from this JSONL schema.

These abbreviated, synthetic examples show the shape, not complete captured events. Ellipses indicate omitted values and must not be fed into a verifier:

{"timestamp_unix_seconds":1700000000,"event_type":"mint","result":"success","root_token_id":"...","agent_decision_action":"forward"}
{"timestamp_unix_seconds":1700000001,"event_type":"receive","result":"failure","failure_code":"ERR_DPOP_REPLAY"}
{"timestamp_unix_seconds":1700000002,"event_type":"receive","result":"failure","failure_code":"ERR_RECIPIENT_NOT_AUTHORIZED"}
{"timestamp_unix_seconds":1700000003,"event_type":"respond","result":"success","root_token_id":"...","agent_decision_action":"settle","terminal_attestation":"..."}

Investigate three common failures

Symptom What to inspect Next step
Replayed proof Receiver HTTP 403 with ERR_DPOP_REPLAY; local receive failure, possibly without a root ID Do not resend a captured AAC/DPoP request. Use the originator/paired-agent API to produce a fresh proof; for a business retry preserve the original A2A dispatch ID and body. Never disable replay checks to retry.
Wrong recipient Receiver ERR_RECIPIENT_NOT_AUTHORIZED; sender may record ERR_DOWNSTREAM_REJECTED Compare destinations.*.audience_pattern to the receiver's exact agent.spiffe_id. A wildcard holder audience does not turn a different final recipient into the intended workload.
Bad pairing Agent HTTP 401 before its business handler; library reports a missing/malformed header, timestamp skew or signature mismatch. Sidecar delivery can report ERR_AGENT_REJECTED Compare the two secret files without printing them, check clocks, method/path, covered headers and exact body bytes. Sign once after serialization and transmit those bytes unchanged.

Keep access logs or request correlation when a pre-verification failure has no root ID. Do not invent a root association from unverified caller headers. After a successful two-agent flow, correlate sender dispatch and receiver receive/respond, then verify the terminal JWS below. An HTTP 200 alone is insufficient evidence of the intended recipient or settlement.

Retain and inspect safely

The sidecar writes file sinks with mode 0600 and fails startup for an unusable path. Restrict access, encrypt backups, define a retention period, and forward to an append-only/tamper-evident store if your audit requirements need it. JSONL itself is not a signed or tamper-proof ledger. Preserve clock/source metadata and test your collector's loss detection. For file rotation, coordinate stop/reopen/restart; renaming an open file does not make the process reopen a new path. Do not rely on log retention as a substitute for retained A2A state.

Central forwarding sends a smaller metadata envelope asynchronously. Payloads, task references, human claims, raw authority/DPoP and terminal attestations stay local. Central outages can lose events without stopping authorization. Use:

aac chain show --help
aac chain show --profile "$AAC_PROFILE" --token-id '<root_token_id>' --output json

Only participating tenants can inspect a trace. A missing central trace can mean delayed/lost forwarding, an unknown ID, or no access; investigate local evidence and coarse forwarding warnings before changing credentials.

Verify terminal evidence offline

Save the local terminal_attestation string from a respond event into terminal.jws. Verify it using your already trusted CA certificates for that workload's SPIFFE domain and the expected tenant, workload, root and task from your own registration/request records. Never derive those expectations or trust anchors from the unverified JWS itself.

Install python -m pip install --upgrade cryptography in an isolated verifier environment. Save the following as verify_terminal.py. It implements the supported direct-CA, single-leaf Ed25519/P-256 profile, a 64-KiB input bound and a fixed 30-second certificate clock tolerance. It checks the signature and identity/correlation bindings; it does not reconstruct the entire AAC chain or prove the business action happened. Pair it with the workflow and application audit records.

import argparse
import base64
import hashlib
import json
import re
import time
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.x509.oid import SignatureAlgorithmOID

ORDER = int('FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 16)
def require(ok, message):
    if not ok:
        raise ValueError(message)

def unb64(value):
    require(bool(re.fullmatch(r'[A-Za-z0-9_-]+', value)), 'Invalid base64url')
    return base64.urlsafe_b64decode(value + '=' * (-len(value) % 4))

def verify(compact, ca_pem, tenant, spiffe, root, task, at):
    require(len(compact) <= 65536, 'Attestation too large')
    parts = compact.split('.')
    require(len(parts) == 3, 'Expected compact JWS')
    header, claims = [json.loads(unb64(p)) for p in parts[:2]]
    require(isinstance(header, dict) and isinstance(claims, dict), 'Expected JSON objects')
    require(header.get('typ') == 'aac-terminal-attestation+jwt' and 'crit' not in header, 'Unsupported protected header')
    require(header.get('alg') in ('EdDSA', 'ES256'), 'Unsupported algorithm')
    chain = header.get('x5c')
    require(isinstance(chain, list) and len(chain) == 1, 'Expected one x5c leaf')
    leaf = x509.load_der_x509_certificate(base64.b64decode(chain[0], validate=True))
    public = leaf.public_key()
    spki = public.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
    kid = base64.urlsafe_b64encode(hashlib.sha256(spki).digest()).rstrip(b'=').decode()
    require(header.get('kid') == kid, 'Leaf-key fingerprint mismatch')
    uris = leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.UniformResourceIdentifier)
    require([u for u in uris if u.startswith('spiffe://')] == [spiffe], 'Unexpected workload identity')
    def valid(cert):
        return cert.not_valid_before_utc.timestamp()-30 <= at <= cert.not_valid_after_utc.timestamp()+30
    require(valid(leaf), 'Leaf outside certificate validity')
    trusted = False
    for ca in x509.load_pem_x509_certificates(ca_pem):
        if not valid(ca):
            continue
        key = ca.public_key()
        try:
            if isinstance(key, ed25519.Ed25519PublicKey) and leaf.signature_algorithm_oid == SignatureAlgorithmOID.ED25519:
                key.verify(leaf.signature, leaf.tbs_certificate_bytes)
            elif isinstance(key, ec.EllipticCurvePublicKey) and isinstance(key.curve, ec.SECP256R1) and leaf.signature_algorithm_oid == SignatureAlgorithmOID.ECDSA_WITH_SHA256:
                key.verify(leaf.signature, leaf.tbs_certificate_bytes, ec.ECDSA(hashes.SHA256()))
            else:
                continue
            trusted = True
            break
        except Exception:
            continue
    require(trusted, 'Leaf not signed by a currently valid trusted CA')
    signature = unb64(parts[2]); data = '.'.join(parts[:2]).encode('ascii')
    require(len(signature) == 64, 'Expected 64-byte signature')
    if header['alg'] == 'EdDSA':
        require(isinstance(public, ed25519.Ed25519PublicKey), 'Algorithm/key mismatch')
        public.verify(signature, data)
    else:
        require(isinstance(public, ec.EllipticCurvePublicKey) and isinstance(public.curve, ec.SECP256R1), 'Algorithm/key mismatch')
        r, s = int.from_bytes(signature[:32], 'big'), int.from_bytes(signature[32:], 'big')
        require(0 < r < ORDER and 0 < s <= ORDER//2, 'Invalid or noncanonical ES256 signature')
        public.verify(encode_dss_signature(r, s), data, ec.ECDSA(hashes.SHA256()))
    expected = {'iss': tenant, 'terminal_agent_svid': spiffe, 'root_token_id': root, 'task_ref': task}
    require(isinstance(root, str) and bool(root), 'Expected root must be nonempty')
    for name, value in expected.items():
        require(claims.get(name) == value, 'Claim mismatch: '+name)
    require(bool(re.fullmatch(r'tnt-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', tenant)), 'Invalid expected tenant')
    require(type(claims.get('iat')) is int and -(2**63) <= claims['iat'] < 2**63, 'Invalid issued-at time')
    require(isinstance(claims.get('settlement_id'), str) and bool(claims['settlement_id']), 'Missing settlement ID')
    require('action_summary' not in claims or isinstance(claims['action_summary'], str), 'Invalid action summary')
    return {'verified': True, **expected, 'settlement_id': claims['settlement_id']}

def read_compact(path):
    with Path(path).open('rb') as stream:
        raw = stream.read(65537)
    require(len(raw) <= 65536, 'Attestation file exceeds 64 KiB')
    return raw.decode('ascii').strip()

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    for flag in ('jws', 'ca', 'tenant', 'spiffe', 'root', 'task'):
        p.add_argument('--'+flag, required=True)
    p.add_argument('--at', type=int, default=None)
    a = p.parse_args()
    result = verify(read_compact(a.jws), Path(a.ca).read_bytes(), a.tenant,
                    a.spiffe, a.root, a.task, int(time.time()) if a.at is None else a.at)
    print(json.dumps(result))
python verify_terminal.py --jws terminal.jws --ca "$AAC_DEMO_DIR/pki/dev-ca.crt" \
  --tenant "$AAC_TENANT_ID" --spiffe "spiffe://${AAC_TRUST_DOMAIN}/demo/agent" \
  --root '<root_token_id returned by your client>' --task '<task_ref from your request>'

For the normal current-time check, omit --at. For historical analysis, use an independently trusted observation time and archived trusted CA material; never use an unverified iat to choose the certificate time. Historical verification does not establish that a key is trusted or a credential active now. The attestation has no expiry claim; iat is signed metadata, not a replay defense. Check it against your independently recorded workflow timeline. For a result from the two-agent example's peer, use its registered spiffe://<trust-domain>/demo/peer identity instead of /demo/agent.

Sidecar error reference

Application errors use this envelope; detail may be null and diagnostic wording may change. Use code for automation and request_id for support. The HTTP status and code together identify the response; the same code can appear at different operation boundaries.

{"error":{"code":"ERR_RECIPIENT_NOT_AUTHORIZED","message":"Recipient verification failed","detail":null,"request_id":"req-aac-sdk-example"}}
Code Meaning and next action
ERR_A2A_AUTHORITY_REGISTRATION_FAILED Verified inbound continuation could not be retained; retry only after the local authority/state problem is resolved.
ERR_A2A_DISPATCH_FAILED Outbound A2A operation failed; inspect local logs and endpoint/TLS configuration before retrying.
ERR_A2A_DISPATCH_TIMEOUT A2A operation exceeded its deadline; reconcile the outcome and preserve the dispatch ID/body.
ERR_A2A_DISPATCH_UNCERTAIN Delivery outcome is unknown; do not create a new dispatch ID to repeat a possible business action.
ERR_A2A_OVERLOADED A2A admission capacity is full; apply backoff and check configured concurrency/headroom.
ERR_A2A_REMOTE_REJECTED The peer rejected A2A delivery; inspect its authorized error response and recipient/profile expectations.
ERR_AGENT_REJECTED The local agent returned a rejecting HTTP response; check pairing first, then its application policy/logs.
ERR_AGENT_TIMEOUT Local agent response exceeded its budget; inspect the handler and deadlines.
ERR_AGENT_UNREACHABLE Local agent could not be reached; check process, loopback namespace, port and TLS settings.
ERR_CHAIN_INVALID Chain structure or restrictions are invalid; inspect the submitted chain and attenuation rules.
ERR_CLASS_OF_ACTION_NOT_FOUND Mint/originate named an unknown class; use a configured classes_of_action name.
ERR_CONFIG_ERROR The requested operation lacks valid configuration; inspect signer/material/duration settings and startup logs.
ERR_CONTINUATION_AUTHORITY_UNAVAILABLE Inbound authority is missing or expired for this pair/task/presenter; obtain a new authorized arrival rather than fabricating continuation.
ERR_DESTINATION_NOT_FOUND A decision/envelope named no configured destination; correct its profile name.
ERR_DOWNSTREAM_REJECTED Native downstream delivery was rejected; inspect the peer's response and expected identity/restrictions.
ERR_DOWNSTREAM_TIMEOUT Downstream transport failed or timed out; check TLS, reachability and budgets, and reconcile before retrying business work.
ERR_DPOP_ATH_MISMATCH Proof does not bind the submitted authority credential; transmit the matching chain and proof together.
ERR_DPOP_CHAIN Proof certificate/trust validation failed; check CA publication, algorithms and certificate validity.
ERR_DPOP_HTM Proof HTTP method differs from the request; sign for the actual method.
ERR_DPOP_HTU Proof URL differs from the request; check final scheme/host/port/path and proxy routing.
ERR_DPOP_REPLAY Proof has already been claimed; obtain fresh authorization/proof through supported APIs, preserving business idempotency.
ERR_DPOP_SIG Proof signature or protected-header profile is invalid; check signer, algorithm and exact signed bytes.
ERR_DPOP_TIME Proof time is outside the accepted window; synchronize clocks and generate a fresh proof.
ERR_DPOP_TTL_TOO_LONG Proof lifetime exceeds the supported bound; use the sidecar's supported issuance path.
ERR_DUPLICATE_CONVERGENCE The composite task is already converging; do not trigger a second concurrent convergence.
ERR_EGRESS_DISPATCH_IN_PROGRESS The same A2A operation is still running; back off and retry the same ID/body.
ERR_EGRESS_IDEMPOTENCY_CONFLICT An existing dispatch ID was reused with different authenticated bytes; preserve the original request or start a genuinely new operation.
ERR_EGRESS_IDEMPOTENCY_SATURATED Retained dispatch capacity is exhausted; check retention and qualified entry/byte budgets.
ERR_EGRESS_RESPONSE_TOO_LARGE Response exceeded the retained-result bound; reconcile the operation and review the configured response limit.
ERR_HMAC_CHAIN_MISMATCH Chain authentication failed; reject the chain and check its serialized/delegated form.
ERR_INTERNAL_ERROR An internal operation failed; retain the request ID and sanitized evidence for support.
ERR_INTERNAL_VERIFIER_ERROR Verification failed internally; stop relying on that attempt and report the request ID.
ERR_INVALID_AGENT_DECISION The agent returned an invalid/unsupported decision or used it at the wrong workflow stage; fix the handler response.
ERR_INVALID_MINT_INPUT Native authority input is invalid or conflicts: check predicate names, canonical integers, byte limits and task_ref consistency; remove request valid_until and use class valid_for.
ERR_PAIRING_AUTH_FAILED Native chain-start pairing failed (401): sign the exact POST alias/raw body with this pair's secret, use fresh canonical headers once each, and check clock skew.
ERR_INVALID_AGENT_RESPONSE Agent/peer response does not match the supported response schema; validate the integration contract.
ERR_INVALID_OIDC_ISSUER Originator issuer is invalid for this request; use the verified application's correct issuer.
ERR_INVALID_REQUEST Request shape, headers, pairing or protocol metadata are invalid; inspect status/detail and the endpoint's request schema.
ERR_INVALID_REQUEST_ENCODING Request text/encoding is invalid; send the supported UTF-8 representation.
ERR_MISSING_AAC_MACAROON No required AAC authority credential was supplied; use the supported sending sidecar.
ERR_MISSING_DPOP_PROOF No required presenting proof was supplied; use the supported sending sidecar.
ERR_MISSING_REQUIRED_HEADER A required operation header is absent; supply the documented value.
ERR_PRESENTER_NOT_PREVIOUS_HOLDER Presenting workload is not the previous authorized holder; correct the sender/chain binding.
ERR_RECIPIENT_NOT_AUTHORIZED Receiving workload is not the exact final recipient; correct the destination audience.
ERR_REPLAY_AUTHORITY_INCOMPATIBLE Replay service/profile is incompatible; restore the supported authenticated retained-write-safe configuration.
ERR_REPLAY_AUTHORITY_QUARANTINED Replay authority is in its safety quarantine; keep admission closed until it is ready.
ERR_REPLAY_AUTHORITY_SATURATED Replay storage has reached its safe bound; Basic admits new claims as old records expire. Size for all new replay claims, including requests rejected later. Never evict unexpired records to make room.
ERR_REPLAY_AUTHORITY_TIMEOUT Replay decision timed out; check service health/latency rather than bypassing it.
ERR_REPLAY_AUTHORITY_UNAVAILABLE Replay authority is unavailable; restore it before admitting requests.
ERR_REQUEST_TOO_LARGE Body/header/operation limit was exceeded; reduce the request or qualify an appropriate configured limit.
ERR_ROOT_SIGNATURE_INVALID Root signature is invalid; check root key ID, trust publication and signed content.
ERR_ROUTE_NOT_FOUND Method/path is unsupported on that listener; check endpoint and port.
ERR_STATE_STORE_UNAVAILABLE Local workflow arrival state is unavailable; repair the state service before retrying.
ERR_T0_ON_WIRE A root-only token was sent where a delegated chain is required; use the sidecar's normal dispatch path.
ERR_T1_ON_WIRE Initial holder authority was sent without a peer delegation; use the normal dispatch path.
ERR_TOKEN_EXPIRED Authority has expired; obtain a new authorized task/chain.
ERR_TOKEN_FORMAT Serialized authority cannot be decoded as the supported token format; do not alter or hand-construct it.
ERR_TOKEN_NOT_FOUND Required buffered arrival/token is absent; check task/token correlation and lifecycle.
ERR_UNKNOWN_PREDICATE A restriction name is unsupported; use the published predicate vocabulary.
ERR_UNKNOWN_TENANT_KEY Referenced tenant/root key is not in the trusted set; check tenant ID, key ID, publication and revocation.
ERR_UNSUPPORTED_MEDIA_TYPE Content type is unsupported; use the endpoint's documented JSON media type.

Authenticated A2A protocol errors can instead use JSON-RPC errors under HTTP 200: -32700 parse error, -32600 invalid request, -32601 unknown method, -32602 invalid parameters, -32004 unsupported operation and -32009 unsupported version. Inspect the JSON body even when HTTP transport succeeded. Admission/authentication/body-size errors may use non-200 HTTP responses.

CLI/control-plane errors are a separate surface. For example, ERR_API_KEY_LAST_ACTIVE belongs to API-key retirement, not sidecar receiving; its documented recovery is to issue/migrate a second active key first.

Pairing authentication for any language

Implement AAC1-HMAC-SHA256 before trusting a sidecar's /invoke or /a2a/v1 callback. A paired agent also uses it to call /v1/agent/a2a/dispatch on the sidecar's loopback listener. It is separate from the AAC-chain/DPoP verification between sidecars. No Python package is required by another language.

  1. Read the per-pair secret file as opaque bytes, removing surrounding ASCII whitespace. Use at least 32 secret bytes. The recommended openssl rand -hex 32 produces 64 ASCII bytes: do not hex-decode those characters.
  2. Preserve the exact request-body bytes. Compute their SHA-256 and encode it as 64 lowercase hexadecimal characters. Do not parse/reserialize the body between signing and sending or verifying.
  3. Select every header whose lowercase name starts with x-aac-, excluding x-aac-invoke-signature and x-aac-invoke-timestamp. Reject duplicate covered names, including duplicates that differ only by case. Lowercase names, preserve their received values, sort by name, and join name:value lines with LF (\n). Do not add a trailing LF to this header block.
  4. Join the following six strings with LF, encoded as UTF-8:
AAC1-HMAC-SHA256
UPPERCASE_HTTP_METHOD
REQUEST_PATH
DECIMAL_UNIX_SECONDS
LOWERCASE_HEX_SHA256_OF_BODY
SORTED_COVERED_HEADER_BLOCK

REQUEST_PATH is the endpoint path, such as /invoke, not a full URL. These paired endpoints use no query string. The final block may contain multiple lines. If it is empty, the canonical string ends in the LF separating it from the body digest; otherwise there is no final LF. Ordinary headers such as Content-Type are not covered. Add no spaces around : in covered lines.

  1. Compute HMAC-SHA256 of that complete string using the secret bytes. Send: X-AAC-Invoke-Timestamp: <decimal seconds> and X-AAC-Invoke-Signature: AAC1-HMAC-SHA256 <lowercase hex HMAC>.
  2. The receiver requires exactly one timestamp and signature, the supported algorithm, a timestamp within 30 seconds before or after its clock, and a constant-time signature match before invoking business logic. Preserve all raw covered-header instances until duplicate checks finish.

The freshness window is not a nonce/replay cache. A captured paired request can still be fresh within that window; network isolation and secret custody remain required. Business retries use the operation's own idempotency rules, including the A2A dispatch_id and exact-body rule.

Signed context header Meaning after pairing verification
X-AAC-Root-Token-Id Root identifier for correlation
X-AAC-Presenter-Token-Id Current presented chain identifier
X-AAC-Hop-Index Chain hop index
X-AAC-Originator-Tenant-Id Root's originator tenant
X-AAC-Presenter-Spiffe-Id Verified presenting workload
X-AAC-Task-Ref Task restriction/correlation; preserve it in the supported response
X-AAC-Envelope-Schema Required value aac.a2a.egress.v1 for paired-agent A2A dispatch

Before the signature check these are caller-controlled strings. Use the documented body/envelope schema too; a valid HMAC alone does not grant broader authority or make an arbitrary callback body valid.

Pairing test vector

The deterministic values below are for interoperability tests only. Never use this secret for a deployment. canonical contains literal LF characters after JSON decoding; its last character is the final o in task-demo.

{
  "secret_ascii": "0123456789abcdef0123456789abcdef",
  "method": "POST",
  "path": "/invoke",
  "timestamp": 1700000000,
  "body_utf8": "{\"task\":\"demo\"}",
  "headers": {
    "X-AAC-Task-Ref": "task-demo",
    "X-AAC-Hop-Index": "2",
    "Content-Type": "application/json"
  },
  "canonical": "AAC1-HMAC-SHA256\nPOST\n/invoke\n1700000000\n74d517bf80045934cfecf491867e9b328c71686f935ca9c57e469ccc28868e3a\nx-aac-hop-index:2\nx-aac-task-ref:task-demo",
  "signature_header": "AAC1-HMAC-SHA256 a931b3d7e2cfc7625f6dc00a75721ff297a51df006926519f922ecef440c20c5"
}

Python agents can use the published library's sign_invoke_request and verify_invoke_request functions, as the runnable example does. Its pairing errors are MissingInvokeAuthHeader, MalformedInvokeAuthHeader, InvokeTimestampOutsideWindow and InvokeSignatureMismatch; the supplied middleware rejects unauthenticated requests with HTTP 401 before business logic. WeakInvokeAuthSecret prevents startup with fewer than 32 secret bytes. These library error names are distinct from the sidecar's ERR_* envelope.

Alternative standalone-binary installation with ORAS

Use this alternative to the sidecar container for a bare VM, systemd host, or macOS development machine. Container users can also download the bundle for offline documentation or deep audit without installing its standalone binary. Install ORAS to download the files and install Cosign to verify their signatures. Neither tool is needed to run the sidecar afterward.

mkdir aac-sidecar-v0.4.4
cd aac-sidecar-v0.4.4
bundle_ref=docker.io/cascadeauth/aac-sidecar:v0.4.4-bundle
bundle_digest="$(oras resolve "${bundle_ref}")"
[[ "${bundle_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]

cosign verify \
  --certificate-identity 'https://github.com/CascadeAuth/aac-sidecar-go/.github/workflows/release.yml@refs/heads/main' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "docker.io/cascadeauth/aac-sidecar@${bundle_digest}"

oras pull "docker.io/cascadeauth/aac-sidecar@${bundle_digest}"

cosign verify-blob \
  --certificate-identity 'https://github.com/CascadeAuth/aac-sidecar-go/.github/workflows/release.yml@refs/heads/main' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  --bundle checksums.txt.bundle \
  checksums.txt

bash ./verify-developer-beta.sh . v0.4.4

Optional deep artifact audit

For a reproducible audit beyond signature/checksum verification, install Python 3.10+ and Go, then run this after the Cosign verification above:

python3 --version
go version
bash ./verify-developer-beta.sh . v0.4.4 --deep-audit

Go reads embedded build metadata from each binary; it does not execute foreign-platform binaries. The verifier's final version check executes only your host's binary. The optional audit checks:

It prints a JSON audit summary with the binary hashes and inventory counts. Archive bounds fail closed if an input exceeds the supported release profile. The audit verifies the signed build metadata and artifact contents; it does not reproduce the build. A green audit does not replace Cosign identity verification, runtime readiness, or your tenant's qualification.

Select only the archive for the current platform. Default developer install:

version=v0.4.4
platform=linux_amd64  # or linux_arm64, darwin_amd64, darwin_arm64
install_root="${HOME}/.local/lib/aac-sidecar/releases/${version}"

mkdir -p "${install_root}" "${HOME}/.local/bin"
tar -xzf "aac-sidecar_${version}_${platform}.tar.gz" -C "${install_root}"
ln -sfn "${install_root}/aac-sidecar" "${HOME}/.local/bin/aac-sidecar"
"${HOME}/.local/bin/aac-sidecar" -version

For an Ubuntu ARM64 VM (uname -m returns aarch64), select linux_arm64, not the linux_amd64 default in the example. Install Cosign and ORAS in the verification environment before this standalone path; the container path also uses Docker's Buildx plugin for digest inspection. Do not overwrite an existing installation or delete unrelated services to make the destination appear clean.

For an operator-managed production service, /opt/aac/sidecar/releases/<version> may be root-owned, but the service process itself must run as a dedicated non-root account. Do not default a developer install to a root-owned directory.

Upgrade, rollback, and uninstall

Developer-binary uninstall:

rm "${HOME}/.local/bin/aac-sidecar"
rm -rf "${HOME}/.local/lib/aac-sidecar/releases/v0.4.4"

Do not use those commands for an operator-owned production directory or state volume. Follow the tenant's change, retention, and secure-deletion procedures.

Rotate or revoke credentials deliberately

A binary upgrade does not rotate tenant credentials. Before revocation, identify all clients of the exact key and distinguish planned rotation from compromise:

aac tenant api-key list --profile "$AAC_PROFILE" --output table
aac tenant api-key issue --help
aac tenant api-key retire --help
aac tenant reissue-api-key --help
aac trust-anchor list --profile "$AAC_PROFILE" --output table
aac trust-anchor revoke --help

For planned API-key rotation, issue the second active key, migrate all clients, verify them, then retire the exact old key. The control plane refuses to retire the last ACTIVE API key (ERR_API_KEY_LAST_ACTIVE); issue and migrate a second key first. For a lost/compromised key, follow the recovery command's separate semantics. The telemetry API-key file is reread on token exchange; existing sessions keep their own expiry.

Root-key rotation uses a new key ID, publishes its public key, waits for trust visibility, moves the workload to it, verifies a real workflow, then revokes the old key. Revoking the last root-signing key is permitted for compromise or teardown; the API-key last-ACTIVE protection does not apply to root keys. Revoked roots leave the public trust set, so verifiers lose trust in chains rooted at that key when their trust view refreshes. Healthy control-plane-backed caches poll every five minutes by default; outage stale-serve behavior remains, and there is no instantaneous global revocation guarantee. Stop affected workflows and coordinate trust refresh/replacement with peers. Do not assume removing a public root erases a workload's locally held signing key.

Replace workload/terminal keys with matching valid certificates and update trust as required. Replace a compromised pairing secret in both paired processes and restart them together. For remote signers, disable/revoke the exact key version and verify failure without provider/file fallback.

An unsafe beta is removed from recommendations and replaced by a newly reviewed immutable version; its old digest is never overwritten. Pause affected traffic and preserve evidence while deciding whether rollback is safe.

Local uninstall removes software, not a tenant account. For reusable testing, stop the processes and retain tenant registration, credentials in your secret manager, and necessary private state. Remove local credential copies only after verifying custody. Permanent workload/credential retirement is a separate operator action; this guide does not promise a tenant-account deletion API or a complete tenant-offboarding procedure.

Beta boundary and contact

This beta is for evaluation and integration development, not production or safety-critical use. There is no production SLA or guaranteed throughput. Use the included license for permitted use and redistribution terms.

Execution graphs

Use the Agent Execution Graph guide to install aac-aeg, list local runs, and render local and authorized central evidence into self-contained HTML. The public reservation demo prints actual input paths and the render command for each attempt. Application records stay local; central metadata cannot supply private business results.

Release notes

Read release notes and upgrade guidance.