Implement · draft 0.1, revision of 23 October 2026

From reading VXP to running it.

What you need to implement VXP on your own, then three guides, one per role. Each step shows the object that crosses the wire and the normative requirement behind it, linked to its anchor in the specification. Where the reference implementation produced the object, it is the captured one.

captured: real HTTP exchange of the native profile, 22 Oct 2026from the specificationexperimental: native profile and HTTP inbox

Captured objects come from the reference implementation running the native profile against a test company. Hosts are shown as store.example and agent.example, and long tokens are shortened with …. Some captures predate the move of the reference implementation's paths from /sovras/ to /vxp/; both are its own choice, read from the issuer metadata, and both still answer (§8, implementation paths). The test company and its messages are Chilean, in Spanish.

Guide 00 · On your own · native profile experimental

Without SOVRAS, without Poppy, without PACT

VXP can be implemented with its native profile alone. Nothing in VXP Proof, VXP Relationship or the operation requirements depends on Poppy or PACT, and no SOVRAS service takes part. Before the guides, three decisions.

Step 1 · experimental

Run a native-only gateway

  • Discovery is /.well-known/vxp.json. The issuer and its keys come from the issuer's RFC 8414 metadata, which then carries no Poppy member (poppy_domains) and an empty vxp_proof.pact.
  • A verifier anchors the proof by the issuer it knows, with no adapter document in between.
  • A token request whose resource is not the native endpoint has no other profile to go to, and gets invalid_target (RFC 8707).
  • Agent authentication uses OAuth building blocks only: a Client ID Metadata Document, private_key_jwt, DPoP and RFC 8628.

The reference implementation runs this way with both adapters off. Its test reference/test/nativo-solo.test.ts runs the whole cycle with an agent that signs with node:crypto directly, and checks the proof with the independent verifier anchored by the issuer alone. No agent outside SOVRAS has used the native profile yet.

Adapters are optional. If you add them later (Guide 01, step 9), they share the same issuer, authorization server and metadata.

Spec · native-only gateway Spec · §8 native profile
Issuer metadata of a native-only gateway · the members that changefrom the specification
{
  "issuer": "https://store.example/e/ferreteria-demo",
  "authorization_details_types_supported": [
    "https://vxpprotocol.org/rar/capability",
    "https://vxpprotocol.org/rar/relationship"
  ],
  "vxp_proof": {
    "company": "https://store.example/e/ferreteria-demo",
    "domains": ["store.example"],
    "pact": [],
    ...
  }
}
// no "poppy_domains"; no poppy.json, no Agent Card

Step 2

Choose your own paths, and publish them in the metadata

VXP fixes only the well-known locations, /.well-known/vxp.json and the issuer's RFC 8414 metadata, and the native endpoints under the endpoint that vxp.json declares. Every other location is discovered: the key sets, the log's checkpoint and the proof bundle are read from vxp_proof in the metadata. Those paths are your own choice and carry no meaning.

WhatWhere it is fixed or read
DiscoveryFixed: /.well-known/vxp.json
Issuer metadataFixed: RFC 8414, under the issuer
Native endpointsFixed under endpoint: /v1/requests, /v1/proposals/…, /v1/relationships/…
Proof keys, operator keys, logRead from vxp_proof
Relationship keysRead from vxp_relationship.jwks_uri

The paths in the captures on this page are those of the reference implementation. Yours can be any, because verifiers and agents read them from the metadata.

Spec · implementation paths Issuer anchoring
Reference implementation · its own paths, for examplefrom the specification
company proof keys     {issuer}/vxp/proof-jwks.json
operator issuer, keys  {origin}/vxp/operator, …/proof-jwks.json
log                    {host}/vxp/log/v0, {origin}/vxp/log/checkpoint,
                       …/consistency, …/jwks.json
relationship keys      {issuer}/vxp/relationship-jwks.json

// until 22 Oct 2026 under /sovras/, still an alias;
// packages signed before that date keep …/sovras/log/v0 as signed

Step 3

Keep proof keys persistent, and rotate without losing old proofs

  • Statements name their key by kid, the RFC 7638 thumbprint.
  • When you rotate a proof key, keep the earlier public key in your JWKS for as long as proofs signed with it are expected to be verified. Never reuse a retired key.
  • A key removed from the JWKS no longer verifies against live material: the verifier answers unknown_key. Against material saved while the key was published, the same proof still verifies, and a verifier that uses saved material says so, with the date it was obtained.
  • Keys generated in memory and lost when the process stops make every proof they signed unverifiable, unless their public keys were saved.

With VXP_KEYS_DIR, the reference implementation keeps its operator, log and company keys across restarts, and a rotation keeps the earlier public key published. Until 22 October 2026 its keys lived in memory, which is why most proofs of 21 October can no longer be checked by signature (Examples).

SHOULD keep proof keys persistent. MUST NOT reuse a retired key.

Spec · key rotation and historical verification Reading a verdict
JWKS after a rotationfrom the specification
{ "keys": [
  { "kid": "<thumbprint of the new key>", "kty": "EC", "crv": "P-256", ... },
  { "kid": "<thumbprint of the earlier key>", "kty": "EC", "crv": "P-256", ... }
] }
// the earlier public key stays while its proofs may be checked

Guide 01 · For companies

Publish, enforce, prove

A company, or the operator that runs its agent entry for it, publishes where agents find it and which keys speak for it, meets the operation requirements where a request becomes an operation, and signs a chain of statements for every operation with effect.

Step 1 · experimental

Publish the discovery document

Serve /.well-known/vxp.json on the company's domain. It names the organization, the issuer, the native endpoint, the capabilities you decided to offer to outside agents and the negotiation limits. The document carries "status": "experimental": the native profile is experimental in draft 0.1.

MUST organization.domain is the host that served the document, and that domain is listed in vxp_proof.domains of the issuer metadata. If poppy.json exists, its auth.issuer is the same issuer.

MUST NOT carry trust members. Verifiers take them only from the issuer metadata. List only the capabilities you chose to expose; the full release never goes out as is.

Spec · native discovery Spec · §8 native profile
GET /.well-known/vxp.json · 200captured
{
  "protocol": "vxp",
  "version": "0.1",
  "organization": {
    "name": "Ferretería Demo",
    "domain": "store.example"
  },
  "issuer": "https://store.example/e/ferreteria-demo",
  "endpoint": "https://store.example/e/ferreteria-demo/v1",
  "capabilities": [
    {
      "id": "pedido.consultar",
      "effect": "read",
      "access": "delegated",
      "admission": "static",
      "authorization_details_type": "https://vxpprotocol.org/rar/capability"
    },
    {
      "id": "pedido.reprogramar_despacho",
      "effect": "write",
      "access": "delegated",
      "admission": "dynamic",
      "authorization_details_type": "https://vxpprotocol.org/rar/capability",
      "commitment_modes": [
        "firm",
        "conditional"
      ],
      "outcome_proof": true
    },
    {
      "id": "catalogo.consultar",
      "effect": "read",
      "access": "delegated",
      "admission": "static",
      "authorization_details_type": "https://vxpprotocol.org/rar/capability"
    },
    {
      "id": "pedido.crear",
      "effect": "write",
      "access": "delegated",
      "admission": "dynamic",
      "authorization_details_type": "https://vxpprotocol.org/rar/capability",
      "commitment_modes": [
        "firm",
        "conditional"
      ],
      "outcome_proof": true
    },
    {
      "id": "pedido.devolver",
      "effect": "write",
      "access": "delegated",
      "admission": "dynamic",
      "authorization_details_type": "https://vxpprotocol.org/rar/capability",
      "commitment_modes": [
        "firm",
        "conditional"
      ],
      "outcome_proof": true
    }
  ],
  "limits": {
    "max_revisions": 3,
    "proposal_ttl_seconds": 900,
    "max_accept_attempts": 3
  },
  "outcome_proof": {
    "version": "0.1",
    "trust": "issuer_metadata"
  },
  "status": "experimental",
  "vxp": {
    "version": "0.1",
    "relationship": {
      "authorization_details_type": "https://vxpprotocol.org/rar/relationship",
      "company_initiated_actions": [
        "despacho.proponer_cambio",
        "incidencia.avisar",
        "alerta.confirmar_o_bloquear"
      ]
    },
    "proof": {
      "trust": "issuer_metadata"
    }
  }
}

Step 2

Publish the issuer metadata with vxp_proof and vxp_relationship

The trust anchor of everything VXP signs is the RFC 8414 metadata of the company's issuer. The paths in the capture are the reference implementation's own; yours go here, and verifiers read them from here (Guide 00, step 2). Add two parameters: vxp_proof, with the trust members (company, keys, operator, custody, log, domains, PACT faces), and vxp_relationship, with the relationship key and the actions the company may initiate.

  • company is the issuer; jwks_uri sits under the issuer (same scheme and host, path starting with the issuer's path plus /).
  • operator.jwks_uri sits under operator.issuer.
  • custody is mandatory and true: operator_hosted, customer_managed or customer_signed_binding. It is a declaration; verifiers report it and never grant a second root from it.
  • An empty log.witnesses means the level logged is out of reach, and the company does not claim otherwise.
  • Copies of these members in poppy.json or an Agent Card are identical.

MUST publish vxp_relationship.jwks_uri with a key distinct from the proof, Poppy and PACT keys (R5). company_initiated_actions bounds what any relationship may contain (R1).

Spec · §6 VXP Proof R1 R5 Issuer anchoring
GET /.well-known/oauth-authorization-server/e/ferreteria-demo · 200captured
{
  "issuer": "https://store.example/e/ferreteria-demo",
  "token_endpoint": "https://store.example/e/ferreteria-demo/oauth/token",
  "revocation_endpoint": "https://store.example/e/ferreteria-demo/oauth/revoke",
  "device_authorization_endpoint": "https://store.example/e/ferreteria-demo/oauth/device",
  "dpop_signing_alg_values_supported": [
    "ES256"
  ],
  "token_endpoint_auth_methods_supported": [
    "private_key_jwt"
  ],
  "grant_types_supported": [
    "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "urn:ietf:params:oauth:grant-type:device_code",
    "refresh_token"
  ],
  "poppy_domains": [
    "store.example"
  ],
  "authorization_details_types_supported": [
    "https://vxpprotocol.org/rar/capability",
    "https://vxpprotocol.org/rar/relationship"
  ],
  "vxp_proof": {
    "version": "0.1",
    "company": "https://store.example/e/ferreteria-demo",
    "domains": [
      "store.example"
    ],
    "pact": [
      {
        "interface": "https://store.example/a2a/t_ferreteria_demo",
        "authorization_server": "https://store.example/a2a/t_ferreteria_demo/oauth"
      }
    ],
    "jwks_uri": "https://store.example/e/ferreteria-demo/sovras/proof-jwks.json",
    "operator": {
      "issuer": "https://store.example/sovras/operador",
      "jwks_uri": "https://store.example/sovras/operador/proof-jwks.json"
    },
    "custody": "operator_hosted",
    "log": {
      "origin": "store.example/sovras/log/v0",
      "checkpoint_url": "https://store.example/sovras/log/checkpoint",
      "consistency_url": "https://store.example/sovras/log/consistency",
      "jwks_uri": "https://store.example/sovras/log/jwks.json",
      "witnesses": []
    }
  },
  "vxp_relationship": {
    "version": "0.1",
    "jwks_uri": "https://store.example/e/ferreteria-demo/vxp/relationship-jwks.json",
    "company_initiated_actions": [
      "despacho.proponer_cambio",
      "incidencia.avisar",
      "alerta.confirmar_o_bloquear"
    ]
  }
}

Step 3

Keep one key per purpose

Each statement is signed by the role that observed it. The company key signs what the company offers and what it states as the result; the operator key signs what it received, executed and read back. Relationship messages use their own key. All are ES256, and each kid is the RFC 7638 thumbprint of its JWK.

KeySignsPublished at
Company proof keyterms, company_review, outcome, correction, revocationvxp_proof.jwks_uri
Operator proof keyrequest, counter, user_approval, execution, read_backvxp_proof.operator.jwks_uri
Relationship keyvxp-relationship+jwt, vxp-company-request+jwt, vxp-reply-receipt+jwtvxp_relationship.jwks_uri
Log keyCheckpoints vxp-checkpoint+jwtvxp_proof.log.jwks_uri

MUST keep the company proof keys distinct from the keys that sign Poppy tokens or PACT receipts, and the relationship key distinct from all of them.

Spec · statement kinds and signers R5
GET /e/ferreteria-demo/vxp/relationship-jwks.json · 200captured
{
  "keys": [
    {
      "kty": "EC",
      "x": "gPV1wTvuCLA5JAznIo1aDX-ZQz6PCcVnj95rsV-jdhk",
      "y": "tgWVBtBf0C6SZX1LxK4gtY4CBpblOyErdM74XGqkYbQ",
      "crv": "P-256",
      "kid": "6fXDvXvxrP3Imn7benrwP3j8AdrkQBvcvGdxz455FB4",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}

Step 4

Meet the operation requirements where a request becomes an operation

Operation requirements O1 to O7 are behavior, not an interface: VXP defines no endpoint for them. Where an external request turns into an operation on your system, what passes is the intersection of what the person delegated, what the company allows and what the resource admits. The controls that enforce them inside your systems are yours; VXP Proof records the evidence that lets anyone check them afterwards.

RequirementWhat your implementation doesAnswer when it failsSpec
O1 Identity and ownerAn account links to an agent only through an authentication the company controls, with evidence kept. An identifier that matches more than one account never links or discloses alone. The owner of an operation is (profile, client_id, sub, account)Another DPoP key, sub or client_id neither sees nor accepts the operationID1 ID2 ID3
O2 ScopeThe request fits the delegation (authorization_details per operation, RFC 9396) and what the company publishesauthorization_required with the missing detailsNative authorization
O3 Own resourceForeign, missing and unlinked resources answer the same waytarget_not_found / operation_not_foundR6
O4 Terms and commitmentThe company signs the exact revision with its commitment (firm or conditional). Changed terms are a new revision, approved againAn unsigned revision cannot be confirmedP1 Commitment
O5 Company reviewWhen its rule requires it, a person at the company decides on the same digest, after the personNo execution before company_review: approved; denied ends failedE3
O6 One executionEach accepted revision once; of operations competing for a resource through any entry path, only the first that passes the precondition, checked right before executing. Retries carry an Idempotency-KeyLater entries end failed with no effect; resource_busy while one is in progressI2
O7 Read-backWithout a read-back of the company's system there is no succeeded; a contradiction goes to waiting_humanoutcome: unknownP5 P6

MUST every conforming implementation shows this behavior. Before executing, re-evaluate with the token of the accept request the resource grant, the policy and the precondition; if any fails, do not execute.

Spec · §9 operation requirements Requirement, evidence, control Native endpoints
POST /e/ferreteria-demo/v1/requests · 202 · scope rule in actioncaptured
{
  "request_id": "req_AQFXz9wmuQpS",
  "operation": "pedido.reprogramar_despacho",
  "target": {
    "order_ref": "P-1042"
  },
  "state": "authorization_required",
  "projections": {
    "poppy": null,
    "a2a": "TASK_STATE_AUTH_REQUIRED"
  },
  "authorization": {
    "error": "authorization_required",
    "authorization_details": [
      {
        "type": "https://vxpprotocol.org/rar/capability",
        "actions": [
          "pedido.reprogramar_despacho"
        ],
        "identifier": "P-1042"
      }
    ],
    "device_authorization_endpoint": "https://store.example/e/ferreteria-demo/oauth/device",
    "resource": "https://store.example/e/ferreteria-demo/v1"
  }
}

Step 5

Freeze and sign each revision

What the person approves is a revision: immutable, numbered from 1, and only the latest can be accepted. For each revision you issue, sign a terms statement with its digest.

  • terms_digest covers {company, operation_id, revision, summary, terms, expires_at, user_approval_required}: the summary is part of what the person approves.
  • terms carries vxp_salt, at least 128 random bits, new in every revision.
  • commitment.mode comes from what the capability can execute and verify, never from what the agent asks. A capability that depends on a system you do not control is proposed conditional, and each condition is a term name.
  • read_back_terms names the terms the read-back must observe.
  • Past max_revisions the request ends failed with negotiation_limit_exceeded; an expired revision gets proposal_expired.

MUST a revision issued and not signed cannot be confirmed. The limits allow at most one automatic execution attempt per accepted revision.

P1 Commitment Negotiation limits
POST /v1/requests · 201 · proposalcaptured
{
  "id": "prop_op_B6__ICZlCe_c_r1",
  "revision": 1,
  "state": "proposed",
  "summary": "Cambiar el despacho del pedido P-1042 (Taladro percutor 800 W) del 2026-10-24 al 2026-10-28, a Calle Ejemplo 100, Santiago, sin costo.",
  "terms": {
    "pedido": "P-1042",
    "producto": "Taladro percutor 800 W",
    "despacho_actual": "2026-10-24",
    "despacho_nuevo": "2026-10-28",
    "direccion": "Calle Ejemplo 100, Santiago",
    "costo": {
      "amount": "0",
      "currency": "CLP"
    },
    "vxp_salt": "bWCQmiSHNOYk-wXlrNRRSw"
  },
  "commitment": {
    "mode": "firm",
    "requires": []
  },
  "expires_at": "2026-10-22T06:24:18.141Z",
  "user_approval_required": true,
  "limits": {
    "max_revisions": 3,
    "revisions_left": 2,
    "max_accept_attempts": 3,
    "accept_attempts_left": 3
  }
}
seq 1 · terms · signed by the companycaptured
{
  "capability": "pedido.reprogramar_despacho",
  "commitment": {
    "mode": "firm",
    "requires": []
  },
  "company_review_required": false,
  "expires_at": "2026-10-22T06:24:18.141Z",
  "read_back_terms": [
    "pedido",
    "despacho_nuevo"
  ],
  "revision": 1,
  "terms_digest": "sha-256:Bzey-n1cnkgQgkXMl7paOcbWV13hZ2LMgyWQd0lKDoE",
  "user_approval_required": true
}

Step 6

Emit the proof chain in its order

Every operation produces one chain. Each statement is an SD-JWT signed with ES256 (typ: vxp-stmt+sd-jwt), names the previous one by its digest in prev, and carries v, kind, iss, company, op, seq, iat, aud (the agent's client_id), user (the per-company pseudonym) and body.

KindSignerWhen
requestoperatorAlways and only at seq 0, with request_digest and, if sent, idempotency_key_digest
termscompanyOne per revision, consecutive, none after user_approval
counteroperatorBefore user_approval, on the latest revision
user_approvaloperatorOnce, on the latest revision; method is agent_asserted or company_channel
company_reviewcompanyOnce, after user_approval and before execution
executionoperatorOnce, on the approved revision and digest
read_backoperatorAfter execution
outcomecompanyOne, on the latest terms
correction, revocationcompanyAfter a final statement, pointing at the last one

MUST put personal data (address, channel evidence, identifying amounts) in selective disclosures, never in clear. The summary a model writes is never attested: only its digest goes in result_summary_digest.

Statement format Statement kinds Chain rules
Proof chain of op_B6__ICZlCe_c · decoded bodiescaptured
seq 0  request        operator  prev null
       {"channel": "vxp", "client_id": "https://agent.example/agent.json", "request_digest": "sha-256:mfJleoso5oEyt_pqJS3OtbmYam7cyncRBrAcqbKfIrI"}
seq 1  terms          company   prev sha-256:oq0I675Q…
       {"capability": "pedido.reprogramar_despacho", "commitment": {"mode": "firm", "requires": []}, "company_review_required": false, "expires_at": "2026-10-22T06:24:18.141Z", "read_back_terms": ["pedido", "despacho_nuevo"], "revision": 1, "terms_digest": "sha-256:Bzey-n1cnkgQgkXMl7paOcbWV13hZ2LMgyWQd0lKDoE", "user_approval_required": true}
seq 2  user_approval  operator  prev sha-256:SJeiKmU1…
       {"method": "agent_asserted", "revision": 1, "terms_digest": "sha-256:Bzey-n1cnkgQgkXMl7paOcbWV13hZ2LMgyWQd0lKDoE"}
seq 3  execution      operator  prev sha-256:6Xnb9LZk…
       {"attempt": 1, "capability": "pedido.reprogramar_despacho", "release": "sha-256:u1TBBxJXTKta36K5UI_j_myhQaHzbPP2HcF4rBKGxPA", "revision": 1, "terms_digest": "sha-256:Bzey-n1cnkgQgkXMl7paOcbWV13hZ2LMgyWQd0lKDoE"}
seq 4  read_back      operator  prev sha-256:YODOxyFc…
       {"matches_terms": true, "method": "system_read_back", "observed": {"_sd": ["3dZK7r9IlLqVnsEb1FgqYMfInwyVFUSjhkgNh6Yu68w"], "despacho_nuevo": "2026-10-28", "pedido": "P-1042"}, "source": "company_system"}
seq 5  outcome        company   prev sha-256:wBMpHI0f…
       {"claimed_assurance": ["issuer_key_attested", "operator_observed", "operator_reported_read_back", "operator_reported_agent_approval"], "result_summary_digest": "sha-256:aKs3uy77c1osgD7qSgTqNJ0_YITroc5iwoYHqFmSl6s", "revision": 1, "state": "succeeded", "terms_digest": "sha-256:Bzey-n1cnkgQgkXMl7paOcbWV13hZ2LMgyWQd0lKDoE", "third_party": []}

Step 7

Sign what you do not know as unknown, and correct it later

  • Pending. If you do not know whether the effect happened, sign outcome.state = "unknown". Silence and failed are both wrong. Poppy sees in_progress and the operation stays open (P3).
  • Learned later. What you learn after a final statement goes as a correction on the last final statement, never as a second outcome. A correction to succeeded cites a matching read_back issued after the statement it corrects (P4).
  • Contradiction. If the read-back differs from what was approved, sign the read_back with matches_terms: false and what was observed; the result stays unknown, the operation moves to waiting_human, with no retry and no success (P5).
  • Not checkable. A capability with no read-back signs read_back.method = "none" and outcome = unknown, once. It never emits succeeded (P6).

MUST before outcome: succeeded, after the execution, the last read_back has method: "system_read_back", matches_terms: true and observes, under the same name, every term in read_back_terms. Statements are never edited or deleted.

P3 P4 P5 P6 Corrections
outcome while the effect is unknownfrom the specification
{ "kind": "outcome",
  "body": { "state": "unknown",
            "revision": 1,
            "terms_digest": "sha-256:<digest of the latest terms>",
            "result_summary_digest": "sha-256:<digest of result.summary>",
            "claimed_assurance": [ ... ] } }
correction once a later read-back matchesfrom the specification
{ "kind": "correction",
  "body": { "corrects": "sha-256:<digest of the last final statement>",
            "state": "succeeded",
            "reason": "<why the result changed>",
            "read_back": "sha-256:<digest of a later matching read_back>" } }
read_back on a contradictionfrom the specification
{ "kind": "read_back",
  "body": { "method": "system_read_back",
            "source": "company_system",
            "matches_terms": false,
            "observed": { "<term name>": "<value actually read>" } } }

Step 8 · HTTP inbox experimental

Offer the relationship and stay inside it

Creation. The agent asks in the Device Authorization (RFC 8628) with an authorization_details of type https://vxpprotocol.org/rar/relationship. Its actions are a subset of your company_initiated_actions, and you cap the limits to your own ceilings (draft 0.1: 10 per day and 90 days). The agent declares its inbox in its Client ID Metadata Document, same origin as its client_id; without it, answer invalid_authorization_details. The consent screen shows the relationship in business language with its own checkbox: unchecked, no relationship is created.

Agreement. The token response carries relationships[] with relationship_id, state, actions, limits, expires_at and the signed agreement (typ: vxp-relationship+jwt; iss, aud, sub, rel, actions, limits, iat, exp, jti).

Initiating. Check everything before signing or sending anything:

Condition that failsError
The relationship does not existrelationship_not_found
The relationship or its delegation was revokedrelationship_revoked
The relationship expiredrelationship_expired
The action is outside the relationshipaction_not_authorized
Daily limit reachedrelationship_limit_exceeded
The resource belongs to another customertarget_not_found

Then sign a vxp-company-request+jwt (iss, aud, sub, rel, action, content, iat, exp of 1 h in draft 0.1, jti), deliver it with POST {inbox_uri} and body {"message": "<jws>"}, and record the delivery and its HTTP result. A proposed change creates a normal operation, with signed terms, user_approval_required: true and its proof chain; proposing never executes. Accepting re-checks the relationship and the delegation: revoked in between, the answer is 403 relationship_revoked with no effect. Revoking the delegation revokes the relationship.

Agents without an inbox. A Poppy agent receives the relationship inside its Poppy conversation, as messages with data["vxpprotocol.org/relationship"]: relationship_offer, then relationship_agreement, then company_request events with role: company, and a signed reply_receipt (vxp-reply-receipt+jwt) for its one answer. The approval page sits on your own domain and reveals the agreement only after a code sent through a channel you have linked to the person. A live personal agent ran this path once, through the Poppy adapter, on 21 October 2026.

MUST reject before signing or sending when any R6 condition fails; register every outgoing message and its result.

Spec · §7 VXP Relationship R1 R2 R3 R4 R6 R7 R8 R9 R10 R12 to R16
Device authorization request · authorization_detailscaptured
[
  {
    "type": "https://vxpprotocol.org/rar/capability",
    "actions": [
      "pedido.reprogramar_despacho"
    ],
    "identifier": "P-1042"
  },
  {
    "type": "https://vxpprotocol.org/rar/relationship",
    "actions": [
      "despacho.proponer_cambio"
    ],
    "identifier": "relationship",
    "limits": {
      "max_per_day": 2,
      "duration_seconds": 3600
    }
  }
]
Token response · relationships[]captured
"relationships": [
  {
    "relationship_id": "rel_JFmlHKqmlCA7",
    "state": "active",
    "actions": [
      "despacho.proponer_cambio"
    ],
    "limits": {
      "max_per_day": 2
    },
    "expires_at": "2026-10-22T07:09:18.000Z",
    "agreement": "eyJhbGciOi…"
  }
]
Outgoing record of a company-initiated proposalcaptured
{
  "outbox_id": "out_fQu1FIVuaYQN",
  "delivery": "entregado",
  "http": 202,
  "content": {
    "request_id": "req_lXXONI2mL8vd",
    "request_url": "https://store.example/e/ferreteria-demo/v1/requests/req_lXXONI2mL8vd",
    "proposal_id": "prop_op_NBbN47CrhrtM_r1",
    "operation_id": "op_NBbN47CrhrtM",
    "summary": "Cambiar el despacho del pedido P-1043 (Escalera de aluminio 6 peldaños) del 2026-10-27 al 2026-10-30, a Calle Ejemplo 100, Santiago, sin costo.",
    "terms_digest": "sha-256:oduNxopNIIA-Iv1iqMDfnh_6QOQXtz0Zg6h34VN2z0g"
  }
}
Poppy conversation path · data membersfrom the specification
// offer (R12)
{ "type": "relationship_offer", "approval_url": "...", "actions": [...], "limits": {...} }
// creation (R14)
{ "type": "relationship_agreement", "relationship_id": "...", "actions": [...],
  "limits": {...}, "expires_at": "...", "agreement": "<vxp-relationship+jwt>" }
// request (R15), on a message event with role: company
{ "type": "company_request", "relationship_id": "...", "action": "...", ...content,
  "message": "<vxp-company-request+jwt>" }
// receipt of the agent's answer (R16)
{ "type": "reply_receipt", "alert_id": "...", "decision": "...",
  "receipt": "<vxp-reply-receipt+jwt>" }

Step 9

Add Poppy draft 0.2 and PACT 1.0 adapters on the same issuer

A company may publish vxp.json, poppy.json and the PACT Agent Card at once. The issuer, the authorization server and the metadata are the same for the three entries, and an agent that speaks Poppy or PACT needs nothing native.

  • I1. Each adapter follows the external contract literally: its objects, states, errors and discovery. Inside an adapter, VXP leaves Poppy's and PACT's discovery, authorization, operations and states exactly as they are.
  • I4. What VXP adds travels in each protocol's extension points: a domain-named member in Poppy, metadata or extensions in A2A. Never as new values of their enums. The native state reaches A2A as metadata["https://vxpprotocol.org/ext/native/v0.1"] and stays out of Poppy's state.
  • I5. Nothing of VXP enters Poppy's or PACT's catalogs: the relationship checkbox is no scope on the Agent Card.
  • I2. However many adapters, competing operations on one resource execute at most once among them. Entries produce separate operations, one per identity; the first to pass the precondition takes effect, the others end failed with no effect and with their evidence.

Keep the official suites green with VXP present: the Poppy validator and the PACT Identity suite are the check that the contract stayed literal.

MUST project each internal state to the visible enum of the profile, using only values that exist there.

Spec · interoperability rules I1 I4 I5 State projection
Poppy operation · extension memberfrom the specification
"vxpprotocol.org/proof": {
  "latest": "<SD-JWT of the latest statement>",
  "bundle_url": "{endpoint}/operations/{operation_id}"
}
A2A / PACT response · message.metadatafrom the specification
"metadata": {
  "https://vxpprotocol.org/ext/proof/v0.1": {
    "operation_id": "...", "latest": "...", "bundle_url": "..."
  },
  "https://vxpprotocol.org/ext/native/v0.1": {
    "state": "<native state>", "a2a_state": "<projection>"
  }
}
// pact.receipt stays beside it, untouched
NativePoppyA2A TaskState
receivedno operationTASK_STATE_SUBMITTED
authorization_requiredno operationTASK_STATE_AUTH_REQUIRED
proposedproposedTASK_STATE_INPUT_REQUIRED
accepted, waiting_human, executing, pending_verificationin_progressTASK_STATE_WORKING
succeededsucceededTASK_STATE_COMPLETED
failedfailedTASK_STATE_FAILED
cancelled, expiredcancelled, expiredTASK_STATE_CANCELED

Checklist for companies

  1. vxp.json served on your domain, organization.domain equal to the host, no trust members, marked experimental. discovery
  2. Issuer metadata with vxp_proof (true custody, keys under their issuers) and vxp_relationship. R5
  3. Four keys with four purposes; proof keys distinct from Poppy and PACT keys. signers
  4. Operation requirements O1 to O7 met, and re-evaluated on accept before executing. §9
  5. Every revision signed, salted, with its commitment and read_back_terms. P1
  6. Chain in order, each kind signed by its role, personal data only in disclosures. chain rules
  7. No succeeded without a matching read-back; unknown when unsure; later facts as correction. P6
  8. Relationship checks run before anything is signed or sent; outgoing messages recorded. R6
  9. Adapters literal, extensions only at extension points, official suites green. I1
  10. Every object you return matches the JSON Schemas, and the conformance validator passes against your server. validator

Guide 02 · For agent platforms · native profile experimental

Discover, ask, act once, keep the record

The native profile lets an agent that speaks neither Poppy nor PACT run the full cycle. Every call carries Authorization: DPoP and a DPoP proof. If your agent already speaks Poppy draft 0.2 or PACT 1.0, use those entries; the company serves them on the same issuer.

Step 1

Discover the company

Fetch https://<domain>/.well-known/vxp.json. Require organization.domain to equal the host that served it. Then fetch the RFC 8414 metadata of issuer and require its issuer to match character by character. From there you learn the token and device endpoints, the supported authorization_details types and the relationship actions the company may initiate.

The capabilities list holds only what the company chose to offer; each carries access (public or delegated) and admission (static or dynamic).

Native discovery Issuer anchoring
Two requests, in this ordercaptured
GET https://store.example/.well-known/vxp.json  200
GET https://store.example/.well-known/oauth-authorization-server/e/ferreteria-demo  200

Step 2

Identify yourself and the person you act for

  • Your client_id is the URL of your Client ID Metadata Document.
  • Authenticate with private_key_jwt against that document.
  • Bind the token with DPoP (RFC 9449, ES256).
  • Send the person's User ID for this company as an RFC 7523 assertion with sub = User ID, on every token request.
  • Send resource = the native endpoint (RFC 8707).

To hold a relationship over HTTP, declare extensions["vxpprotocol.org/relationship"].inbox_uri in the same document, on the same origin as your client_id.

MUST the token is DPoP-bound and every native request carries resource = endpoint. The User ID is a stable per-company pseudonym, never a phone or an email.

Native authorization R2 §5 Identity model
Client ID Metadata Document of the reference test agentfrom the reference tests
{
  "client_id": "https://agent.example/agent.json",
  "client_name": "Asistente de prueba (red)",
  "jwks_uri": "https://agent.example/jwks.json",
  "token_endpoint_auth_method": "private_key_jwt",
  "extensions": {
    "operations": { "version": "1" },
    "vxpprotocol.org/relationship": {
      "version": "0.1",
      "inbox_uri": "https://agent.example/vxp/inbox"
    }
  }
}
POST /e/ferreteria-demo/oauth/token · 200captured
DPoP: eyJ0eXAiOiJkcG9wK2p3dC…

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
client_id=https://agent.example/agent.json
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=eyJhbGciOi…
assertion=eyJhbGciOi…
resource=https://store.example/e/ferreteria-demo/v1

{"access_token": "nt_6E1g…", "token_type": "DPoP", "expires_in": 3600}

Step 3

Ask for authority one operation at a time

Create the request. Without a grant for that resource, the company answers 202 authorization_required and names exactly what is missing. Start a Device Authorization (RFC 8628) with those authorization_details, and add a relationship detail if you want one. The person opens the company's page, links their account with a code sent by a channel the company controls, sees each identifier in business language and decides. Poll the token endpoint with the device_code: the response returns the details granted.

  • https://vxpprotocol.org/rar/capability authorizes asking for an operation on a resource.
  • Approving one exact revision needs no further grant: you accept it at POST /v1/proposals/{id}/accept, and the company re-checks the grant before executing.

MUST the authorization server checks that each identifier belongs to the linked account; otherwise invalid_authorization_details.

Native authorization ID1
POST /e/ferreteria-demo/oauth/device · 200captured
{
  "device_code": "dc_CUIk…",
  "user_code": "FSXD-NWZJ",
  "verification_uri": "https://store.example/e/ferreteria-demo/vincular",
  "verification_uri_complete": "https://store.example/e/ferreteria-demo/vincular?user_code=FSXD-NWZJ",
  "expires_in": 600,
  "interval": 5
}
POST /oauth/token (device_code) · 200captured
{
  "access_token": "nt_hbbt…",
  "token_type": "DPoP",
  "expires_in": 3600,
  "authorization_details": [
    {
      "type": "https://vxpprotocol.org/rar/capability",
      "actions": [
        "pedido.reprogramar_despacho"
      ],
      "identifier": "P-1042"
    }
  ],
  "relationships": [
    {
      "relationship_id": "rel_JFmlHKqmlCA7",
      "state": "active",
      "actions": [
        "despacho.proponer_cambio"
      ],
      "limits": {
        "max_per_day": 2
      },
      "expires_at": "2026-10-22T07:09:18.000Z",
      "agreement": "eyJhbGciOi…"
    }
  ]
}

Step 4

Retry with the same Idempotency-Key

POST /v1/requests requires an Idempotency-Key. The key counts per (issuer, client_id, User ID, Idempotency-Key). With the new token, send the same key and the same body: the company re-evaluates the same request instead of creating another, and here it moved from authorization_required to proposed.

  • Same key, other body: 409 idempotency_key_reused.
  • accept and cancel are idempotent per revision and state, with no key.

MUST the digest of the key goes in request.body.idempotency_key_digest of the proof chain.

Native idempotency
POST /e/ferreteria-demo/v1/requests · 201captured
Authorization: DPoP nt_hbbt…
DPoP: eyJ0eXAiOiJkcG9wK2p3dC…
Idempotency-Key: BKjzb0e35jUf3RPm

{
  "operation": "pedido.reprogramar_despacho",
  "target": {
    "order_ref": "P-1042"
  },
  "parameters": {
    "delivery_date": "2026-10-28"
  }
}

{
  "request_id": "req_AQFXz9wmuQpS",
  "operation": "pedido.reprogramar_despacho",
  "target": {
    "order_ref": "P-1042"
  },
  "state": "proposed",
  "projections": {
    "poppy": "proposed",
    "a2a": "TASK_STATE_INPUT_REQUIRED"
  },
  "operation_id": "op_B6__ICZlCe_c",
  "proposal": {
    "id": "prop_op_B6__ICZlCe_c_r1",
    "revision": 1,
    "state": "proposed",
    "summary": "Cambiar el despacho del pedido P-1042 (Taladro percutor 800 W) del 2026-10-24 al 2026-10-28, a Calle Ejemplo 100, Santiago, sin costo.",
    "terms": {
      "pedido": "P-1042",
      "producto": "Taladro percutor 800 W",
      "despacho_actual": "2026-10-24",
      "despacho_nuevo": "2026-10-28",
      "direccion": "Calle Ejemplo 100, Santiago",
      "costo": {
        "amount": "0",
        "currency": "CLP"
      },
      "vxp_salt": "bWCQmiSHNOYk-wXlrNRRSw"
    },
    "commitment": {
      "mode": "firm",
      "requires": []
    },
    "expires_at": "2026-10-22T06:24:18.141Z",
    "user_approval_required": true,
    "limits": {
      "max_revisions": 3,
      "revisions_left": 2,
      "max_accept_attempts": 3,
      "accept_attempts_left": 3
    }
  },
  "effects": [],
  "cancellation": null,
  "result": null,
  "proof_url": "https://store.example/e/ferreteria-demo/v1/requests/req_AQFXz9wmuQpS/proof"
}

Step 5

Show the exact terms, then accept that revision

Show the person the summary and the terms of the revision, including its commitment and conditions. Accept with POST /v1/proposals/{id}/accept and {approved_by}: "user" when the person approved these terms, or "standing_permission" only when the revision says user_approval_required: false because the terms fall within a limit the person approved in advance. To negotiate, send POST /v1/proposals/{id}/counter with {proposed_terms, note?}: a counter never executes by itself, and only a revision issued by the company can be accepted.

  • 409 terms_changed brings the current proposal: show it again before accepting.
  • 202 means accepted and not final yet; 200 means it finished.
  • An approval through the agent is recorded as agent_asserted: it says the agent stated the person approved.

Native endpoints

POST /v1/proposals/prop_op_B6__ICZlCe_c_r1/accept · 200captured
{
  "approved_by": "user"
}

{
  "request_id": "req_AQFXz9wmuQpS",
  "operation": "pedido.reprogramar_despacho",
  "target": {
    "order_ref": "P-1042"
  },
  "state": "succeeded",
  "projections": {
    "poppy": "succeeded",
    "a2a": "TASK_STATE_COMPLETED"
  },
  "operation_id": "op_B6__ICZlCe_c",
  "proposal": {
    "id": "prop_op_B6__ICZlCe_c_r1",
    "revision": 1,
    "state": "accepted",
    "summary": "Cambiar el despacho del pedido P-1042 (Taladro percutor 800 W) del 2026-10-24 al 2026-10-28, a Calle Ejemplo 100, Santiago, sin costo.",
    "terms": {
      "pedido": "P-1042",
      "producto": "Taladro percutor 800 W",
      "despacho_actual": "2026-10-24",
      "despacho_nuevo": "2026-10-28",
      "direccion": "Calle Ejemplo 100, Santiago",
      "costo": {
        "amount": "0",
        "currency": "CLP"
      },
      "vxp_salt": "bWCQmiSHNOYk-wXlrNRRSw"
    },
    "commitment": {
      "mode": "firm",
      "requires": []
    },
    "expires_at": "2026-10-22T06:24:18.141Z",
    "user_approval_required": true,
    "limits": {
      "max_revisions": 3,
      "revisions_left": 2,
      "max_accept_attempts": 3,
      "accept_attempts_left": 3
    }
  },
  "effects": [
    {
      "system": "erp",
      "change": "despacho 2026-10-24 → 2026-10-28",
      "applied": true
    }
  ],
  "cancellation": null,
  "result": {
    "summary": "Listo: el pedido P-1042 se despacha el 2026-10-28 (antes 2026-10-24). Verificado en el sistema de Ferretería Demo.",
    "data": {
      "pedido": "P-1042",
      "despacho": "2026-10-28",
      "verificado": "lectura_del_sistema",
      "effects": [
        {
          "system": "erp",
          "change": "despacho 2026-10-24 → 2026-10-28",
          "applied": true
        }
      ]
    }
  },
  "reason": "read_back_matches",
  "proof_url": "https://store.example/e/ferreteria-demo/v1/requests/req_AQFXz9wmuQpS/proof"
}

Step 6

Follow the state, and cancel as a request to stop

GET /v1/requests/{id} returns the native state, its projections to Poppy and A2A, the proposal, effects, cancellation, result and proof_url. GET /v1/requests/{id}/events lists every transition. A read never changes the state by itself; it may trigger reconciliation, which never repeats the action.

POST /v1/requests/{id}/cancel before execution gives cancelled with no effect. During executing or pending_verification it is a request to stop: 202, the state stays, and cancellation reports requested, then stopped or not_stopped.

The four final states, succeeded, failed, cancelled and expired, never change. Later facts reach you as a correction in the proof.

§4 States Cancellation
GET /v1/requests/{id}/events · shape of each transitionfrom the specification
{ "seq": 2, "from": "proposed", "to": "accepted", "at": "...", "reason": "...",
  "poppy": "in_progress", "a2a": "TASK_STATE_WORKING" }
Cancel during execution · 202from the specification
"cancellation": { "requested_at": "...", "status": "requested",
                  "reversal_promised": false }

Step 7

Fetch the proof, verify it and keep it

GET /v1/requests/{id}/proof returns the bundle {v, company, op, statements[], log}. Verify it as a third party would (Guide 03), with the issuer you anchored at discovery, not with anything inside the bundle. Store each bundle with its inclusion proofs and ask later for consistency.

SHOULD show the person the level the verifier derived, never the claimed_assurance the company wrote.

Proof verification P7
GET /v1/requests/req_AQFXz9wmuQpS/proof · 200captured
{
  "v": "0.1",
  "company": "https://store.example/e/ferreteria-demo",
  "op": "op_B6__ICZlCe_c",
  "statements": [
    "eyJhbGciOiJFUzI1NiIsImtp…",
    "eyJhbGciOiJFUzI1NiIsImtp…",
    "eyJhbGciOiJFUzI1NiIsImtp…",
    "eyJhbGciOiJFUzI1NiIsImtp…",
    "eyJhbGciOiJFUzI1NiIsImtp…",
    "eyJhbGciOiJFUzI1NiIsImtp…"
  ],
  "log": {
    "origin": "store.example/sovras/log/v0",
    "checkpoint": "eyJhbGciOiJFUzI1NiIsImtp…",
    "cosignatures": [],
    "inclusion": [
      {
        "seq": 0,
        "leaf_index": 0,
        "salt": "fySjQc9-jRDZ_Jfar99-Jo57lQRc90HU_3i1rEQgI10",
        "tag": "gqPsl5kgcDjd3t-HbEJpMaXL7jst-75Q_euEWlzU5_I",
        "path": [
          "XtUdDVZxYxBMhj_wITgI1AlozZZTvEr6N00ByLNjP5s",
          "hfcCoA5vi850hpsIXToQNodPsDqB7rbNQEW2RIX8Oww",
          "QtT_OcYX-lXTZ-pGm9cewhEWvmRNih2mMmfU_z0XE5M"
        ]
      },
      {
        "seq": 1,
        "leaf_index": 1,
        "salt": "9KV0fBi71q9FAFmN7JRWk6v0cw589kjCLVskMpvqFCU",
        "tag": "_SL-kPrRlFoCUhnT8qVlQp4sNYLIgrckV2tZdt0EpFU",
        "path": [
          "kIteOOtOpkzuKSSdUm0FI2LqWg9EX8WBFxA_4-VZ7kA",
          "hfcCoA5vi850hpsIXToQNodPsDqB7rbNQEW2RIX8Oww",
          "QtT_OcYX-lXTZ-pGm9cewhEWvmRNih2mMmfU_z0XE5M"
        ]
      },
      {
        "seq": 2,
        "leaf_index": 2,
        "salt": "Coni-e34eG5Yk22DBoqmByAZIT9p4AVq1eKdbfwv_2w",
        "tag": "c0gDq2QVWSTRmsv1BPMV7qewxHyZF7UUAv3Rf9HCfFs",
        "path": [
          "L78gshP-ZtVBf494YxaZpd2htruAvSDG0bhUQ7PZu0E",
          "FDWAa6Xs-HIWTiyoMOagKCdu0QenK0zL-LT4QTGuKK4",
          "QtT_OcYX-lXTZ-pGm9cewhEWvmRNih2mMmfU_z0XE5M"
        ]
      },
      {
        "seq": 3,
        "leaf_index": 3,
        "salt": "Ed8lKJE3k9TcPQ-gTjK4dWpAUWQ3Fopsvl4hSlOTIco",
        "tag": "ZLdMrb1D0jktZgIVYgVqFFnhqyEGUdRiaHtKfBvn1o0",
        "path": [
          "IEkCECx4g2NDQuo12vkIBYeyhmjozS_eGfsj6zi-gkE",
          "FDWAa6Xs-HIWTiyoMOagKCdu0QenK0zL-LT4QTGuKK4",
          "QtT_OcYX-lXTZ-pGm9cewhEWvmRNih2mMmfU_z0XE5M"
        ]
      },
      {
        "seq": 4,
        "leaf_index": 4,
        "salt": "UhjWlQcKVcVTNCTULid1iHsyi6t_5lVPk7VMNZ3WvQA",
        "tag": "p11Sw_4Drue3zutvjeqSxoVudUZT13NwRZxE8yDq6oc",
        "path": [
          "utjHel5zUJe-1mMLKHBtpAsMdAwNFijqfb43zbOrv6U",
          "yjI_xu6KARUesPeRWyESTFkTVWvPZjIOvIVAt6UxwRk"
        ]
      },
      {
        "seq": 5,
        "leaf_index": 5,
        "salt": "9_X-Gt6p2TOfc9z8H10744eaY8lSfnwnH9LSW86atn4",
        "tag": "cfxcvBSY1r09wUu3ZN853VCZEXHfDqs2QjPPB0ppg4g",
        "path": [
          "_cD3m-8j7wIakyk9o-F6sNACcEQ5F3H4qKrpPjH6AfI",
          "yjI_xu6KARUesPeRWyESTFkTVWvPZjIOvIVAt6UxwRk"
        ]
      }
    ]
  }
}

Step 8

Keep the relationship

HTTP inbox experimental. The company posts {"message": "<jws>"} to your inbox_uri. Verify the vxp-company-request+jwt with the key at vxp_relationship.jwks_uri of the issuer you anchored, and check aud, sub, rel, action and exp. A proposed change cites request_url, proposal_id and terms_digest: fetch the request, show the person the terms and accept through the normal accept endpoint, with their approval. Query your relationship with GET /v1/relationships/{id} and end it with DELETE /v1/relationships/{id}.

Poppy conversation. Ask for the relationship in your own words inside the conversation, with the account already linked. Read relationship_offer and relationship_agreement like any event, receive each company_request on a message event with role: company, and answer once, before it expires, with one of the options by text or by data["vxpprotocol.org/relationship"] = {alert_id?, decision}. Keep the signed reply_receipt.

MUST verify the signature with the key anchored to the issuer. SHOULD reject a repeated jti.

R7 R8 R10 R11 §7 Poppy conversation
Claims of vxp-company-request+jwtfrom the specification
header  { "alg": "ES256", "typ": "vxp-company-request+jwt", "kid": "..." }
payload { "iss": "<company issuer>", "aud": "<your client_id>", "sub": "<User ID>",
          "rel": "<relationship_id>", "action": "despacho.proponer_cambio",
          "content": { "request_url": "...", "proposal_id": "...", "terms_digest": "..." },
          "iat": ..., "exp": ..., "jti": "..." }
Accepting the company's proposal · POST …/prop_op_NBbN47CrhrtM_r1/accept · 200captured
{
  "approved_by": "user"
}

{
  "request_id": "req_lXXONI2mL8vd",
  "operation": "pedido.reprogramar_despacho",
  "target": {
    "order_ref": "P-1043"
  },
  "state": "succeeded",
  "projections": {
    "poppy": "succeeded",
    "a2a": "TASK_STATE_COMPLETED"
  },
  "operation_id": "op_NBbN47CrhrtM",
  "proposal": {
    "id": "prop_op_NBbN47CrhrtM_r1",
    "revision": 1,
    "state": "accepted",
    "summary": "Cambiar el despacho del pedido P-1043 (Escalera de aluminio 6 peldaños) del 2026-10-27 al 2026-10-30, a Calle Ejemplo 100, Santiago, sin costo.",
    "terms": {
      "pedido": "P-1043",
      "producto": "Escalera de aluminio 6 peldaños",
      "despacho_actual": "2026-10-27",
      "despacho_nuevo": "2026-10-30",
      "direccion": "Calle Ejemplo 100, Santiago",
      "costo": {
        "amount": "0",
        "currency": "CLP"
      },
      "vxp_salt": "bldx9qk42Wxhv9S29cZ1rg"
    },
    "commitment": {
      "mode": "firm",
      "requires": []
    },
    "expires_at": "2026-10-22T06:24:19.653Z",
    "user_approval_required": true,
    "limits": {
      "max_revisions": 3,
      "revisions_left": 2,
      "max_accept_attempts": 3,
      "accept_attempts_left": 3
    }
  },
  "effects": [
    {
      "system": "erp",
      "change": "despacho 2026-10-27 → 2026-10-30",
      "applied": true
    }
  ],
  "cancellation": null,
  "result": {
    "summary": "Listo: el pedido P-1043 se despacha el 2026-10-30 (antes 2026-10-27). Verificado en el sistema de Ferretería Demo.",
    "data": {
      "pedido": "P-1043",
      "despacho": "2026-10-30",
      "verificado": "lectura_del_sistema",
      "effects": [
        {
          "system": "erp",
          "change": "despacho 2026-10-27 → 2026-10-30",
          "applied": true
        }
      ]
    }
  },
  "reason": "read_back_matches",
  "proof_url": "https://store.example/e/ferreteria-demo/v1/requests/req_lXXONI2mL8vd/proof"
}

Step 9

What to do with each error

ErrorHTTP · whereWhat it meansWhat your agent does
authorization_required202 on request · 403 on acceptThe grant for this resource is missing or no longer covers itStart the device authorization with the authorization_details returned, then retry with the same key
idempotency_key_required400 · requestNo Idempotency-KeySend one; reuse it on every retry of the same request
invalid_request400 · requestThe body is malformedFix the body; do not retry as is
target_not_found404 · requestThe resource is missing, foreign or unlinked; all answer the sameTell the person; do not probe other identifiers
idempotency_key_reused409 · requestSame key, different bodyUse a new key for a new request
request_exists, resource_busy409 · requestA request already covers it, or an operation is in progress on the resourceFollow the existing one; wait for its final state
request_not_found404 · readUnknown, or owned by another (profile, client_id, sub, account)Check you use the same identity and key that created it
terms_changed409 · acceptThe revision you accepted is no longer the latestShow the current proposal it returns and ask again
proposal_expired409 · acceptThe revision passed its expires_atCreate a new request
user_approval_required403 · acceptstanding_permission was used on a revision that needs the person's own approvalAsk the person, then accept with approved_by: "user"
proposal_not_acceptable409 · acceptThe request is already finalRead the final state and the proof
attempt_limit_exceeded409 · acceptToo many accepts on stale revisions; the request ends failed with no effectStart over with a new request
counter_rejected409 · counterThe company keeps its current revisionShow it; accept, counter differently or let it expire
negotiation_limit_exceeded409 · counterNo more revisions; the request ends failedTell the person; nothing ran
invalid_authorization_details400 · device or tokenAn identifier is outside the linked account, or a relationship was asked without an inboxAsk only for the person's own resources; declare inbox_uri or use the Poppy conversation
relationship_revoked403 · acceptThe person revoked the relationship or its delegation; nothing ranStop acting on that relationship

Native endpoints · Negotiation limits · Errors

Checklist for agent platforms

  1. Discovery host and issuer checked before any token request. discovery
  2. Client ID Metadata Document published; private_key_jwt, DPoP ES256, User ID assertion and resource on every token request. authorization
  3. Authority asked per resource with authorization_details, through the device flow. RFC 9396
  4. One Idempotency-Key per request, reused on every retry. idempotency
  5. The person sees the exact summary and terms before you accept that revision. accept
  6. Final states treated as final; cancel during execution read as a request to stop. cancel
  7. Every proof verified against your own anchor and stored; derived level shown. verification
  8. Company messages verified with the relationship key, repeated jti rejected, one answer per question. R7
  9. Each failure handled as Failures and how VXP behaves describes; the library, CLI and MCP server in agent/ do all of the above. guide

Guide 03 · For independent verifiers

Check who stated what, and name the root

A verifier takes three inputs: the bundle, an anchor it knows on its own, and its policy. It returns a verdict with the levels it derived. Two conforming verifiers give the same verdict, or the same error code at the same seq, on any chain.

Step 1

Anchor the issuer from what you already know

Start from the company's domain or issuer, known by your own means. Never from a document or URL handed to you by whoever presents the bundle: anyone can publish keys on their own domain that claim to be the company.

  1. Fetch the RFC 8414 metadata of the issuer and require its issuer identical, character by character.
  2. Take the trust members from its vxp_proof: company equal to the issuer, jwks_uri under the issuer, operator.jwks_uri under operator.issuer.
  3. If you use a copy in poppy.json or an Agent Card, require it identical in those members.
  4. Any failure, including a 404, a download error or non-JSON, gives issuer_not_anchored: answer with the verdict, never crash.

Anchors per profile: poppy_domain, brand_domain, pact_issuer or issuer. The reference verifiers report key_source: issuer_metadata_live. If you verify against material saved earlier, for example after a key rotation, say so, with the date the material was obtained (key rotation).

MUST anchor before checking any signature. The anchor ties the keys to whoever controls the issuer, which can be the operator under a company domain.

Issuer anchoring
Verifier output · anchor fieldscaptured
{
  "company": "https://store.example/e/ferreteria-demo",
  "anchor": "issuer",
  "key_source": "issuer_metadata_live",
  "custody_declared": "operator_hosted"
}

Step 2

Check format and signatures

  • SD-JWT of RFC 9901 §4, last component empty, no Key Binding JWT.
  • Canonical base64url in every segment and disclosure: decode, re-encode and compare (non_canonical_encoding).
  • Header alg: ES256, typ: vxp-stmt+sd-jwt, no crit; recompute kid as the RFC 7638 thumbprint.
  • The kid is known for that iss in the anchored JWKS, and the signature is valid.
  • The payload bytes are exactly JCS(payload) (non_canonical_payload).
  • The kind fits the signer's role (role_mismatch).
  • Apply RFC 9901 §7.1 to the disclosures; the later rules use the processed payload.

Statement format · Chain rules 1 to 4

seq 4 · decoded header, payload and disclosurecaptured
{
  "alg": "ES256",
  "kid": "MzmIkJfQxG0g2S4h37FyrCxfuebOFnyfMQZsdjhsbmE",
  "typ": "vxp-stmt+sd-jwt"
}

{
  "aud": "https://agent.example/agent.json",
  "body": {
    "matches_terms": true,
    "method": "system_read_back",
    "observed": {
      "_sd": [
        "3dZK7r9IlLqVnsEb1FgqYMfInwyVFUSjhkgNh6Yu68w"
      ],
      "despacho_nuevo": "2026-10-28",
      "pedido": "P-1042"
    },
    "source": "company_system"
  },
  "company": "https://store.example/e/ferreteria-demo",
  "iat": 1792649358,
  "iss": "https://store.example/sovras/operador",
  "kind": "read_back",
  "op": "op_B6__ICZlCe_c",
  "prev": "sha-256:YODOxyFcs7NEMSud9v7eDJqGvptF9PCf4aGL6BEEPfM",
  "seq": 4,
  "user": "usr_red_b8L3VdEl",
  "v": "0.1"
}

disclosure: ["DH5rCbd9iQ_a3oF5Thu3gQ", "direccion", "Calle Ejemplo 100, Santiago"]

Step 3

Check the chain, in the specified order

  • v, company, op, aud and user are equal across the chain, and aud equals the agent verifying if it declares itself.
  • seq = i, and prev is the digest of statement i − 1: "sha-256:" ‖ base64url(SHA-256(ASCII(header "." payload))), the JWS signing input without signature or disclosures.
  • After a final statement, only read_back, correction and revocation fit.
  • Semantic order per kind: one user_approval on the latest revision, company_review between approval and execution, one execution on the approved digest, one outcome on the latest terms.
  • There is an outcome; a bundle without one is treated as unknown (no_outcome).

MUST check the rules in their order, per seq. The first failure decides the code and its seq.

Chain rules
Chain error codes, by rulefrom the specification
1  not_sd_jwt  non_canonical_encoding  bad_header  unknown_issuer
   unknown_key  signature_invalid
2  non_canonical_payload
3  role_mismatch
4  bad_sd_alg  bad_sd  disclosure_malformed  weak_salt  disclosure_bad_name
   disclosure_claim_exists  sd_digest_repeated  disclosure_repeated
   disclosure_digest_mismatch
5  bad_version  company_mismatch  op_mismatch  aud_mismatch  user_mismatch
6  seq_error  chain_broken
7  bad_body  after_final
8  order_violation  revision_error  bad_terms  approval_not_latest_revision
   approval_terms_mismatch  bad_approval_method  display_code_mismatch
   bad_decision  execution_terms_mismatch  bad_read_back
   outcome_terms_mismatch  bad_state  outcome_after_denial
   succeeded_without_matching_read_back  read_back_incomplete
   correction_without_read_back
9  no_outcome

Step 4

Recompute the digests you can

Every digest of a JSON value is "sha-256:" ‖ base64url(SHA-256(UTF-8(JCS(x)))), strings included with their quotes. With the operation the agent saw, recompute terms_digest, require a valid vxp_salt (weak_terms_salt), and compare the state and the digest of the summary.

With company_channel approval, recompute display_code: the first 4 bytes of SHA-256(UTF-8(terms_digest)) as an unsigned big-endian integer, mod 1 000 000, padded to 6 digits.

Operation cross-check codes: operation_terms_mismatch, operation_state_mismatch, operation_result_mismatch, read_back_terms_mismatch.

Proof verification Company-channel approval
Input of terms_digestfrom the specification
JCS({ "company": "...", "operation_id": "...", "revision": 1,
      "summary": "...", "terms": { ..., "vxp_salt": "..." },
      "expires_at": "...", "user_approval_required": true })

Step 5

Check matches_terms against names, not a boolean

succeeded stands only on the last read_back after the execution, with method: "system_read_back" and matches_terms: true. That read-back observes, under the same name, every term the last terms lists in read_back_terms, and that list is never empty. manual and none never back succeeded.

Holding the terms in clear, compare each member of observed with the term of the same name: they hold the same value.

P5 · P6 · read_back_incomplete

terms (seq 1) against observed (seq 4)captured
read_back_terms   ["pedido", "despacho_nuevo"]

terms.pedido          "P-1042"       observed.pedido          "P-1042"      =
terms.despacho_nuevo  "2026-10-28"   observed.despacho_nuevo  "2026-10-28"  =

"operation": "matches"

Step 6

Compare the operation with the last final, never the first

The state in force is that of the last final statement: the outcome, or the latest correction, or revoked after a revocation. Each correction points at the last final statement in corrects; a correction to succeeded cites a later matching read_back. Compare the state the agent sees on the operation with that last final.

MUST compare the visible state of the operation with the last final statement, not with the first outcome.

P8 Corrections
Verifier output · final statecaptured
{
  "valid": true,
  "final_state": "succeeded",
  "revision": 1,
  "approval": "agent_asserted",
  "statements": 6,
  "operation": "matches"
}

Step 7

Derive the levels from the chain, and report the root

Ignore claimed_assurance for every decision. Each level is named after who states it with a signature.

LevelGranted whenRoot
issuer_key_attestedoutcome validly signed by the key anchored to the issuerWhoever controls that key
operator_observedAn execution signed by the operatorThe operator
operator_reported_read_backA read_back with system_read_back and matches_terms: trueThe operator
operator_reported_channel_approvaluser_approval.method = company_channelThe operator
operator_reported_agent_approvaluser_approval.method = agent_assertedThe operator

Report single_root: "operator" unless you hold verifiable evidence that the operator does not control the company key; draft 0.1 defines no such evidence, so it is always "operator". Grant logged only with valid inclusion in a checkpoint cosigned by at least one witness outside the operator. logged, time_stamped, third_party_* and user_confirmed are specified, and no verifier grants them yet.

Assurance levels · P7

Verifier output · derived levels and rootcaptured
{
  "derived_assurance": [
    "issuer_key_attested",
    "operator_observed",
    "operator_reported_read_back",
    "operator_reported_agent_approval"
  ],
  "single_root": "operator",
  "custody_declared": "operator_hosted",
  "logged": false,
  "log_inclusion": "ok",
  "consistency": "ok",
  "witnesses": 0
}

Step 8

State what a signature leaves open

An intact chain proves who stated what, on which exact terms, in which order, and what was observed when reading the company's system, with its root of trust named. It leaves these questions to the named root:

  • whether what was read in the company's system is true;
  • whether the approval through the channel actually happened;
  • under operator_hosted, whether the company took part at all;
  • whether another, contradictory bundle exists on the same resource: one bundle cannot show it, only a log with witnesses can.

As implemented in draft 0.1, without external witnesses and with the company key held by the operator, a VXP proof is an attestation signed by the operator: complete and ordered, and dependent on SOVRAS as operator. Write your policy in these terms, never as verified: true.

§1 Introduction · Reading a verdict · P7 · Proof verification

A policy written in the specification's termsfrom the specification
To refund, require:
  issuer_key_attested
  + operator_reported_read_back
  + logged
  + third_party_signed
  and single_root other than "operator"

Checklist for verifiers

  1. Issuer anchored from your own knowledge; material from the presenter never used as anchor. anchoring
  2. Canonical encoding, header, recomputed kid, signature and JCS payload checked per statement. format
  3. Chain rules applied in order; first failure reported with its code and seq. rules
  4. terms_digest, salt and display_code recomputed when you hold the operation. cross-check
  5. succeeded accepted only with a matching read-back that observes every read_back_terms name. P6
  6. Operation state compared with the last final statement. P8
  7. Levels derived from the chain; single_root and logged reported. P7
  8. Verdict output with valid, company, anchor, key_source, final_state, revision, approval, derived_assurance, single_root, custody_declared and logged. output

Implementation questions

Answers from the specification

Each answer comes from the specification in its revision of 23 October 2026, and links to the section it rests on.

Does a company need Poppy, PACT or the SOVRAS platform to implement VXP?

No. VXP can be implemented with the native profile alone: nothing in VXP Proof, VXP Relationship or the operation requirements depends on Poppy or PACT, and no SOVRAS service is needed. Poppy and PACT are optional adapters for agents that already speak them. The implementation available today is SOVRAS's reference implementation, under Apache 2.0, which runs as a native-only gateway in its tests; the native profile is experimental, and no agent outside SOVRAS has used it yet.

§1 Native-only gateway
My keys were generated in memory and the server restarted. Can old proofs still be checked?

Only if their public keys were saved. Against saved material, a proof still verifies, and the verifier says it used saved material, with its date. Keep proof keys persistent, and when you rotate, keep the earlier public key in your JWKS.

Key rotation
Can one company serve vxp.json, poppy.json and a PACT Agent Card at the same time?

Yes. The issuer, the authorization server and the metadata are the same for the three entries. Entries produce separate operations, one per identity, and only one can take effect on a resource.

§8 I2
How stable is the native profile?

/.well-known/vxp.json and the /v1 endpoints are experimental, and the document says so with "status": "experimental". They run the full cycle in process and over the network; adoption by a third-party agent is pending. While the protocol is a 0.x draft, every new draft is treated as incompatible with the previous one.

§8
My capability has no way to read the result back. What do I emit?

Sign read_back.method = "none" and outcome = unknown, once. Its highest result is unknown; a later correction can take it to failed, cancelled or expired, never to succeeded. It also must not announce read-back in max_assurance.

P6
The execution timed out and I do not know if it applied. Failed?

Sign outcome.state = "unknown". Poppy sees in_progress and the operation stays open. When a later read-back matches, issue a correction to succeeded citing it.

P3 P4
Is the agent saying "the user approved" enough for company_channel?

That approval is agent_asserted, and the verifier names it operator_reported_agent_approval. company_channel means the person answered from the channel the company already linked to them, with a display_code bound to the revision. In the native profile and in Poppy, approval is agent_asserted today; company_channel runs in PACT and on the company's own approval pages.

P2 Company-channel approval
Where does waiting_human appear to a Poppy or A2A agent?

Internal states stay internal. Poppy sees in_progress and A2A sees TASK_STATE_WORKING. Under A2A the internal state may travel in metadata; under Poppy it never goes in the operation's state.

E2 State projection
Can the relationship consent show up as a scope on the Agent Card?

It stays out of every Poppy and PACT catalog. The PACT delegated test caught exactly that in the reference implementation, and it was corrected.

I5
What if the person revokes while a company-initiated proposal is pending?

Accepting re-checks the relationship and the delegation. The answer is 403 relationship_revoked and nothing runs. A late reply on a revoked relationship does not count.

R9 R16
My agent has no HTTP inbox. Can it still hold a relationship?

Through a Poppy conversation, yes: the relationship is asked, received and answered inside it, as messages with data, without touching Poppy's contract. Without an open conversation, a delivery is recorded as failed. Retries with backoff are pending.

§7
Does a valid chain prove the operation was carried out?

No. It proves who stated what, on which exact terms, in which order, and what was observed when reading the company's system, with its root named. Today that root is the operator: single_root: operator, logged: false. A verdict keeps integrity, provenance, authority, declared result and corroboration apart.

§1 Reading a verdict P7
Are the vxpprotocol.org identifiers final?

They are the draft 0.1 names, renamed from the annex on 22 October 2026. vxpprotocol.org was registered that day and is under SOVRAS's control, so the identifiers use a domain the publisher controls, as Poppy requires; this site is published there. While VXP is a 0.x draft, every new draft is treated as incompatible with the previous one.

§8, renamed identifiers Versioning

Take part

Issues and pull requests

The specification, the reference implementation, the verifiers and the vectors live in one repository, github.com/vxp-protocol/vxp, under Apache 2.0. Read CONTRIBUTING.md there before you open anything.

Propose a change or report an error

  • Open an issue or a pull request in the repository.
  • A change to a normative rule cites the rule's identifier, such as R6, P5 or O6, and comes with a test or a test vector that fails today and passes with the change.
  • Errata in the text or the site can go straight to a pull request.

Report a security problem

  • Do not open a public issue for it.
  • Report it privately through the repository's security advisories, as CONTRIBUTING.md explains, with what you found and how to reproduce it.