Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.15.2.
Request Evidence from your application
For the consumer or verifier
Complete Get your first Evidence assertion before starting this
tutorial. There you drove the Evidence boundary from a terminal with evidencectl and curl. Here
you move the same boundary into application code: your program obtains its own access token, sends
one request, and refuses to read the answer until the signed response has satisfied expectations the
program itself retained.
Understand what the client owns
Section titled “Understand what the client owns”%%{init: {"sequence": {"mirrorActors": false}}}%%
sequenceDiagram
participant A as Your application
participant M as Registry Mint
participant E as Evidence
A->>M: Signed client assertion
M-->>A: Short-lived access token
A->>E: One request, one fresh nonce
E-->>A: Signed response, still untrusted
A->>A: Verify against the retained procedure
Note over A: Only a verified payload reaches decision logic
The client library performs the token exchange, the request, and the verification. It does not decide what a valid answer is. Your application states that once, as a relying procedure, and the library refuses every response that does not match it.
Give the application its own identity
Section titled “Give the application its own identity”Enter the existing project:
cd adult-statusAn application authenticates as a registered client, not as the project owner. Define a policy for the question it may ask, then register the client:
evidencectl access policy add app-age-checks --question adult-statusevidencectl access client add age-check-app \ --policy app-age-checks \ --generate-local-keyAdded access policy app-age-checks for adult-status.Added client age-check-app with policy app-age-checks.Both identifiers belong to this tutorial alone. Authoring refuses to overwrite an existing policy or client document, so a page that reused the ids from Control who can request Evidence would refuse for anyone who had followed it, and would inherit the client that tutorial revokes at its end. With their own ids, the two pages compose in one project in either order.
The reviewable registration at access/clients/age-check-app.yaml carries the policy membership and
the public key. The private key stays owner-only at .evidence/clients/age-check-app/private.jwk
and is the application’s identity. The registration also fixes the audience the application must
state for itself:
grep evidenceAudience access/clients/age-check-app.yamlevidenceAudience: urn:registrystack:evidence:local:client:age-check-appA project with no access policy has an unnamed development caller. Registering the first policy removes it, so from now on every request in this project names a client. Control who can request Evidence covers policies, live onboarding, and revocation in full.
Pin the keys your application trusts
Section titled “Pin the keys your application trusts”The application must decide which signing keys it accepts before any response exists. Build a JWKS from the project’s own retained public signing key:
evidencectl jwks --out trusted-issuer-keys.json secrets/signing-p256-public.jwk.jsonwrote trusted-issuer-keys.jsonIn this tutorial the issuer and the relying party are the same person, so a local file stands in for what production requires: keys received over a channel independent of the responses they verify. The client never fetches trust from a response or from a discovery document. See Manage verifier trust and key rotation for the production handling.
Build the Python client
Section titled “Build the Python client”The client is not published to a package index yet, so build the extension module from a checkout of the repository. Take the source at the version of the runtime you installed, not at the default branch. From inside the project directory:
installed="$(evidencectl --version | awk '{print $2}')"git clone --depth 1 --branch "v$installed" \ https://github.com/registrystack/registry-stack.git ../registry-stackcargo build --locked --manifest-path ../registry-stack/Cargo.toml \ -p registry-evidence-client-py --lib \ --features registry-evidence-client-py/extension-moduleThis project is pre-1.0, so its default branch may already carry request and response contract
changes the installed runtime does not implement. Pinning the checkout to the installed version keeps
the client and the deployment on one contract; a mismatch would surface as a discovery or
verification failure that looks like a bug in your application. If the clone fails because that
release does not carry the client yet, install a newer evidencectl and repeat the step. If you
already have a checkout, point the two ../registry-stack paths at it instead of cloning, after
confirming its version in Cargo.toml matches evidencectl --version.
Copy the compiled library into a directory the application imports from:
mkdir -p python-modulecase "$(uname -s)" in Darwin) built=libregistry_evidence_client.dylib ;; Linux) built=libregistry_evidence_client.so ;;esaccp "../registry-stack/target/debug/$built" python-module/registry_evidence_client.soPython imports an extension module from a plain .so name on both platforms. macOS and Linux are
the platforms this build path covers. The build needs python3 on PATH, because the binding
configures itself against the interpreter it will be imported by.
Start the local services
Section titled “Start the local services”Evidence reads the source record through the registry.py server from the first tutorial, whose
cleanup told you to stop it. In the terminal that owns the Python registry, return to the
first-evidence-assertion directory, which holds registry.py one level above the project, and
start the same source again. Leave it running:
python3 registry.pyRegistry listening on http://127.0.0.1:8000Back in the first terminal, compile the question and the access policy into a fresh generation, and start Evidence and Registry Mint:
evidencectl dev --detachEvidence ready at http://127.0.0.1:8080Mint ready at http://127.0.0.1:8081evidencectl dev reports ready without reaching the source, so a stopped registry surfaces only
later, as a failed evidence request.
The access policy is now part of the running generation, so a terminal request names a client too. Confirm that the project no longer accepts an unnamed one:
evidencectl request prepare adult-status \ --purpose age-check \ --subject person_id=person-123 \ --name unnamed-callerevidencectl: the active project requires a registered client selected with --clientRead the definitions once
Section titled “Read the definitions once”Ask the deployment which complete request shapes this client may send, and keep the answer to review:
python3 - <<'PY'import jsonimport sysfrom pathlib import Path
sys.path.insert(0, "python-module")
from registry_evidence_client import EvidenceClient
client = EvidenceClient( base_url="http://127.0.0.1:8080", trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), # This local project has no emergency revocations. In production, load the # current governed denylist independently from the issuer's response. revoked_key_ids=[], token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", "client_id": "age-check-app", "client_key": json.loads( Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, },)document = json.dumps(client.discover(), indent=2, sort_keys=True)Path("discovery.json").write_text(document + "\n")print(document)PY{ "assuranceProfile": "local", "definitions": [ { "concepts": [ { "form": "boolean", "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult" } ], "configurationRevision": "sha256:<revision>", "evidenceType": "urn:registrystack:evidence:local:evidence-type:adult-status", "kind": "criterion", "purpose": "age-check", "referenceFrameworks": [ "urn:registrystack:evidence:local:framework:adult-status" ], "requirement": "urn:registrystack:evidence:local:requirement:adult-status", "subjects": [ { "cardinality": "one", "role": "person", "selector": { "fields": [ { "maximumBytes": 200, "minimumBytes": 1, "name": "person_id", "type": "string" } ], "profile": "local-subject-adult-status-v1", "valueOrigin": "request" } } ] } ], "issuedBy": "urn:registrystack:evidence:local:issuer", "providedBy": "urn:registrystack:evidence:local:provider", "schema": "registry.evidence-definitions/v1"}Discovery is authenticated, and it grants no authority. It answers exactly one question: which complete request shapes this client may send. It is not a trust anchor, and a request must never take an expectation from a discovery response fetched alongside it.
Read it here once, to author the procedure. From now on the procedure supplies every request’s expectations.
Pin the procedure
Section titled “Pin the procedure”Write what you just reviewed into a file the application owns. This step makes no network call: it transforms the document you already read, so the identifiers and the revision are transcribed rather than copied by hand. The constants at the top are the application’s own, stated rather than read:
python3 - <<'PY'import jsonimport sysfrom pathlib import Path
REQUIREMENT = "urn:registrystack:evidence:local:requirement:adult-status"
# Chosen by this application, not published by the deployment.AUDIENCE = "urn:registrystack:evidence:local:client:age-check-app"RESPONSE_FORMAT = "signed-jws"EXPECTED_OUTPUTS = [ { "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", "form": "boolean", },]MAXIMUM_LIFETIME_SECONDS = 300CLOCK_SKEW_SECONDS = 30
published = json.loads(Path("discovery.json").read_text())shapes = [item for item in published["definitions"] if item["requirement"] == REQUIREMENT]if len(shapes) != 1: sys.exit(f"expected exactly one published shape for {REQUIREMENT}, found {len(shapes)}")definition = shapes[0]
# Fail here, at review time, rather than at verification time. Verification# requires the response's value set to match the expectation exactly, so a# concept this application does not expect is as disqualifying as a missing one.offered = {concept["id"]: concept["form"] for concept in definition["concepts"]}expected = {item["concept"]: item["form"] for item in EXPECTED_OUTPUTS}if offered.keys() != expected.keys(): missing = sorted(expected.keys() - offered.keys()) added = sorted(offered.keys() - expected.keys()) sys.exit(f"the published concept set moved: no longer published {missing}, now also {added}")
# One subject, one string selector field, resolved from the request: the shape# this application is written for. A deployment may change any of it while# keeping the identifiers above.[subject] = definition["subjects"]selector = subject["selector"]fields = {field["name"] for field in selector["fields"]}if ( subject["cardinality"] != "one" or subject["role"] != "person" or selector["valueOrigin"] != "request" or fields != {"person_id"}): sys.exit(f"the published subject shape moved: {json.dumps(subject, sort_keys=True)}")
document = json.dumps( { "requirement": definition["requirement"], "purpose": definition["purpose"], "evidence_type": definition["evidenceType"], "issued_by": published["issuedBy"], "provided_by": published["providedBy"], # Published per requirement, so it is read from this definition. It # covers only what this requirement depends on: an unrelated bundle edit # leaves it unchanged, and this procedure keeps verifying. "configuration_revision": definition["configurationRevision"], "expected_assurance_profile": published["assuranceProfile"], "audience": AUDIENCE, "response_format": RESPONSE_FORMAT, "expected_outputs": EXPECTED_OUTPUTS, "maximum_assertion_lifetime_seconds": MAXIMUM_LIFETIME_SECONDS, "clock_skew_seconds": CLOCK_SKEW_SECONDS, # What the deployment published, so a later regeneration diffs it. The # application reads the subject shape from here rather than restating it. "published_shape": { "concepts": offered, "subject": { "role": subject["role"], "selector_profile": selector["profile"], "selector_fields": sorted(fields), }, }, }, indent=2, sort_keys=True,)Path("procedure.json").write_text(document + "\n")print(document)PY{ "audience": "urn:registrystack:evidence:local:client:age-check-app", "clock_skew_seconds": 30, "configuration_revision": "sha256:<revision>", "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", "expected_assurance_profile": "local", "expected_outputs": [ { "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", "form": "boolean" } ], "issued_by": "urn:registrystack:evidence:local:issuer", "maximum_assertion_lifetime_seconds": 300, "provided_by": "urn:registrystack:evidence:local:provider", "published_shape": { "concepts": { "urn:registrystack:evidence:local:concept:adult-status:is_adult": "boolean" }, "subject": { "role": "person", "selector_fields": [ "person_id" ], "selector_profile": "local-subject-adult-status-v1" } }, "purpose": "age-check", "requirement": "urn:registrystack:evidence:local:requirement:adult-status", "response_format": "signed-jws"}procedure.json is the pinned procedure. In a real deployment you review it, commit it, and ship it
with the application. You do not regenerate it at startup: an application that refreshes its
expectations from the deployment it is checking has no expectations of its own.
Four of those settings are the application’s own judgement, and no deployment can supply them:
audienceis the identifier the client registration assigned this application.response_formatexplicitly selects the signed JWS response and the matching offline verifier.maximum_assertion_lifetime_secondsandclock_skew_secondsare its own bounds on how stale an answer it will accept.
expected_outputs is stated by hand for a different reason. A concept’s published form and a
verification expectation’s form are separate vocabularies: a boolean concept is expected as
boolean, but controlled codes and bounded decimals are expected as string, bounded integers as
integer, and the two list forms need explicit bounds. Deriving the expectation from the published
form would work for this requirement and mislead you on the next one.
The concept set is checked against discovery in both directions instead, because verification is
exact: it requires the response’s value set to match expected_outputs one for one. A deployment
that stops publishing a concept and one that adds another both leave this application unable to
verify any response, so both fail at review time here rather than at verification time later.
published_shape is the part of the answer the deployment owns, kept in the file so the next review
can see it move. The application reads the subject role and selector profile from it rather than
restating them, so a changed request shape cannot pass review and then reach a request. The
concepts map records the forms the deployment published beside the expectations authored from them.
subject_expectations is absent from the file because it is per-request: it is what the application
already knows about the subject in front of it.
Regenerating this file is a review step, not a retry. The revision covers this requirement’s own
governed configuration, so it moves when an operator changes something this requirement depends on,
and verification then fails until someone has looked at what changed. A change elsewhere in the
deployment leaves it alone. When it does move, keep the reviewed copy, write a
new one, and diff them before accepting: a changed revision alone is routine, while a changed
evidence_type, issued_by, concept set, or published_shape means the question, or the way it
must be asked, moved.
Write the relying procedure
Section titled “Write the relying procedure”Open age_check.py in your editor and add the application. It loads the pinned procedure and never
calls discovery again:
import fcntlimport jsonimport osimport sysfrom pathlib import Path
sys.path.insert(0, "python-module")
from registry_evidence_client import ( DeniedError, EvidenceClient, EvidenceClientError, NotAvailableError, VerificationError,)
# The reviewed procedure. This program never calls discovery: every expectation# comes from the file that was pinned when it was written.PROCEDURE = json.loads(Path("procedure.json").read_text())# The reviewed request shape. Removed from the procedure because `prepare` takes# the expectations and the subjects, and this is neither.SUBJECT = PROCEDURE.pop("published_shape")["subject"]IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult"BINDINGS = Path("subject-bindings.json")BINDINGS_LOCK = Path("subject-bindings.lock")
def build_client(): """Configure the one deployment this application talks to.""" return EvidenceClient( base_url="http://127.0.0.1:8080", trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), # This local project has no emergency revocations. In production, load # the current governed denylist independently from the issuer response. revoked_key_ids=[], token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", "client_id": "age-check-app", "client_key": json.loads( Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, }, )
def expectations_for(person_id): """Pin a binding this application has already seen, or accept first use.""" if BINDINGS.exists(): return json.loads(BINDINGS.read_text()).get(person_id, "accept_first_use") return "accept_first_use"
def remember(person_id, pinned): """Add one binding under an exclusive lock, without dropping another run's.""" with BINDINGS_LOCK.open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} store[person_id] = pinned pending = BINDINGS.with_name(BINDINGS.name + ".pending") document = json.dumps(store, indent=2, sort_keys=True) + "\n" # The replacement is a new file, so its permissions come from this call # and not from the store it replaces. Owner-only from creation, whatever # umask the shell that runs this happens to carry. descriptor = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with open(descriptor, "w") as pending_file: pending_file.write(document) pending.replace(BINDINGS)
def ask_is_adult(client, person_id): spec = dict( PROCEDURE, subjects=[ { "role": SUBJECT["role"], "selector_profile": SUBJECT["selector_profile"], "selector_values": {"person_id": person_id}, }, ], subject_expectations=expectations_for(person_id), ) verified = client.request_and_verify(client.prepare(spec)) answers = { value["providesValueFor"]: value["value"] for value in verified.evidence["supportedValues"] } return answers[IS_ADULT], verified.pinned_subject_expectations
person_id = sys.argv[1] if len(sys.argv) > 1 else "person-123"try: is_adult, pinned = ask_is_adult(build_client(), person_id)except VerificationError as error: sys.exit(f"unverifiable response, nothing read ({error.code}): {error}")except DeniedError as error: sys.exit(f"refused by the deployment (status {error.status}): {error}")except NotAvailableError as error: sys.exit(f"no evidence available: {error}")except EvidenceClientError as error: sys.exit(f"exchange did not complete ({error.kind}): {error}")
remember(person_id, pinned)print(f"{person_id} is_adult={is_adult}")print(f"pinned binding recorded in {BINDINGS}")Six properties of that code are the point of this tutorial:
- Every expectation, and the request shape itself, comes from
procedure.json. The program holds no discovery client and cannot learn what to expect from the deployment it is checking. prepareperforms no network call. It closes the request, retains the explicit response format, generates a fresh nonce, and builds the verification policy while the answer is still unknown. A prepared request is good for one send.request_and_verifyreturns only after the response satisfied the policy. There is no object in this program that holds a decoded but unverified payload.- The answer is read from a mapping keyed by the concept identifier, never by position, so a response carrying different values cannot be misread as this one.
- Three failures get their own branch, and
EvidenceClientErrorcatches the rest. Every path exits. Nothing falls through to a default answer. rememberreads and rewrites the whole store, so it holds an exclusive lock across both and replaces the file atomically. Two runs finishing at once would otherwise each write what they read, and the loser’s subject would silently return to accepting a binding on first use. A file lock covers one host; an application on more than one needs a transactional per-subject store.
Run it
Section titled “Run it”The recorded subject binding is scoped to this audience and purpose, so keep it owner-only:
umask 077python3 age_check.pyperson-123 is_adult=Truepinned binding recorded in subject-bindings.jsonThe first run had nothing to pin, so it accepted the binding on first use and recorded it. Run it again:
python3 age_check.pyperson-123 is_adult=Truepinned binding recorded in subject-bindings.jsonThe second run pinned the recorded binding, and the response had to carry that exact value. First use proves only that a response was signed for the request that was sent; pinning is what ties later answers to the same subject your application saw before.
The binding is a keyed one-way value the deployment computes, so its stability has a scope. It is stable for the same subject while the audience, the purpose, the role, the selector profile, and the deployment’s own binding key and key version are all unchanged, and it is unrelated for any other audience or purpose. An operator who rotates that key, or increments its version, changes every binding the deployment issues. Treat that as a coordinated event rather than a mismatch: the deployment announces it, and each application re-enrolls by discarding its stored bindings and accepting first use once more per subject. Discarding them without that announcement gives up exactly the continuity the pinning provides, and a changed selector profile has the same effect, which is why the review step above refuses one.
Ask about a different record:
python3 age_check.py person-456person-456 is_adult=Falsepinned binding recorded in subject-bindings.jsonThe registry holds a name and a date of birth for both people. Neither answer contains either.
Refuse before reading
Section titled “Refuse before reading”Change one stored binding to prove that the application, not the deployment, decides what it accepts:
python3 - <<'PY'import jsonfrom pathlib import Path
store = json.loads(Path("subject-bindings.json").read_text())store["person-123"] = store["person-456"]Path("subject-bindings.json").write_text(json.dumps(store, indent=2, sort_keys=True) + "\n")PYpython3 age_check.py person-123unverifiable response, nothing read (policy): the Evidence response failed verification: Evidence payload does not match the relying procedureEvidence answered the request successfully. The client discarded the response because it did not
match the retained expectation, and age_check.py exited without reading a value. Editing the
configuration_revision in procedure.json fails the same way, and for the same reason.
Delete subject-bindings.json to start the pinning over.
Branch on the exception class or on kind, never on the message text, which is not frozen:
kind | Class | Meaning |
|---|---|---|
configuration | ConfigurationError | The client cannot be used as configured, or a prepared request was already sent. |
nonce | NonceError | The request nonce could not be generated. |
token | TokenError | No credential could be obtained. Read token_kind. |
transport | TransportError | The exchange failed below the HTTP layer. Read transport_kind. |
denied | DeniedError | The deployment refused with a coded problem response. |
not_available | NotAvailableError | The deployment answered that no evidence is available. |
protocol | ProtocolError | The deployment answered outside its contract, or the response could not be parsed. |
verification | VerificationError | A signed response failed offline verification. Read code. |
Every class above inherits from EvidenceClientError, which carries kind. The status and the body’s
code decide the class together, and only for the pairs the problem contract registers: HTTP 401,
403, and 429 map to denied, and 422 carrying the contract’s no-evidence code maps to
not_available. Every other status maps to protocol, as does any of those four statuses carrying a
code the contract does not register for it, because that is a body the deployment did not promise. So
not_available is the branch for a request that was answered and had no evidence to report, not a
protocol failure. No exception carries response bytes, a credential, a header value, a selector
value, or a subject binding.
Two failures sit outside that hierarchy, because neither is a mapped failure of the exchange: the
client’s internal runtime failing to start raises RuntimeError, and a serialization failure on a
value the client itself built raises ValueError.
Stop the local services
Section titled “Stop the local services”evidencectl dev stopevidencectl dev cleanLocal Evidence stoppedRemoved stopped local Evidence stateThis keeps age_check.py, discovery.json, procedure.json, the pinned JWKS, the recorded
bindings, and the client’s private key.
Return to the first terminal and press Ctrl+C to stop registry.py.
- Verify Evidence as a consumer, for re-verifying a stored response at the recorded decision time
- Control who can request Evidence, for policies and revocation
- Request an access token from your own code, for the token exchange without the client library
- Review the Evidence Gateway API