From 915e9b7266a3093fc9c2ecddf5210374a57038e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 00:56:17 +0700 Subject: [PATCH 1/3] docs(evidence): teach relying-party integration in application code The consumer tutorial states the verification boundary abstractly. A relying party still has to decide where the trusted key set, the request policy, and the subject binding live in its own program. Walk that journey once with the Python client: give the application its own identity, pin the issuer keys out of band, build the expectations while the answer is still unknown, and read a value only after offline verification returns. The page is deliberately not registered in EVIDENCE_TUTORIALS: the executable tutorial gate mounts the repo read-only and injects only the Evidence binaries, so the Python extension module cannot be built inside it. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + .../request-evidence-from-an-application.mdx | 508 ++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 3517f9043..c7bc8a44a 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -337,6 +337,7 @@ export default defineConfig({ label: 'Verify and trust', collapsed: true, items: [ + { label: 'Request from an application', slug: 'tutorials/request-evidence-from-an-application' }, { label: 'Verify and retain an assertion', slug: 'tutorials/verify-an-assertion-as-a-consumer' }, { label: 'Enable SD-JWT VC', slug: 'tutorials/request-evidence-as-sd-jwt-vc' }, { label: 'Manage verifier trust', slug: 'tutorials/manage-evidence-verifier-trust' }, diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx new file mode 100644 index 000000000..02f3ceb00 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -0,0 +1,508 @@ +--- +title: Request Evidence from your application +description: Give an application its own identity, request an adult-status assertion with the Python Evidence client, and read the answer only after offline verification. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-06" +doc_type: tutorial +persona: + - consumer or verifier +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Complete [Get your first Evidence assertion](../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 + +```mermaid +%%{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 + +Enter the existing project: + +```sh +cd adult-status +``` + +An application authenticates as a registered client, not as the project owner. Define a policy for +the question it may ask, then register the client: + +```sh +evidencectl access policy add age-checks --question adult-status +evidencectl access client add age-checker \ + --policy age-checks \ + --generate-local-key +``` + +```text +Added access policy age-checks for adult-status. +Added client age-checker with policy age-checks. +``` + +The reviewable registration at `access/clients/age-checker.yaml` carries the policy membership and +the public key. The private key stays owner-only at `.evidence/clients/age-checker/private.jwk` and +is the application's identity. The registration also fixes the audience the application must state +for itself: + +```sh +grep evidenceAudience access/clients/age-checker.yaml +``` + +```text +evidenceAudience: urn:registrystack:evidence:local:client:age-checker +``` + +A 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](../control-who-can-request-evidence/) covers policies, live +onboarding, and revocation in full. + +## 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: + +```sh +evidencectl jwks --out trusted-issuer-keys.json secrets/signing-ed25519-public.jwk.json +``` + +```text +wrote trusted-issuer-keys.json +``` + +In 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](../manage-evidence-verifier-trust/) for the production +handling. + +## 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. From inside the project directory: + +```sh +git clone --depth 1 https://github.com/registrystack/registry-stack.git ../registry-stack +cargo build --locked --manifest-path ../registry-stack/Cargo.toml \ + -p registry-evidence-client-py --lib \ + --features registry-evidence-client-py/extension-module +``` + +If you already have a checkout, point the two `../registry-stack` paths at it instead of cloning. + +Copy the compiled library into a directory the application imports from: + +```sh +mkdir -p python-module +case "$(uname -s)" in + Darwin) built=libregistry_evidence_client.dylib ;; + Linux) built=libregistry_evidence_client.so ;; +esac +cp "../registry-stack/target/debug/$built" python-module/registry_evidence_client.so +``` + +Python 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 + +Compile the question and the access policy into a fresh generation, and start Evidence and Registry +Mint: + +```sh +evidencectl dev --detach +``` + +```text +Evidence ready at http://127.0.0.1:8080 +Mint ready at http://127.0.0.1:8081 +``` + +The `registry.py` server from the first tutorial must still be running in its own terminal. Evidence +reads the source record through it. + +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: + +```sh +evidencectl request prepare adult-status \ + --purpose age-check \ + --subject person_id=person-123 \ + --name unnamed-caller +``` + +```text +evidencectl: the active project requires a registered client selected with --client +``` + +## Read the definitions once + +Ask the deployment which complete request shapes this client may send: + +```sh +python3 - <<'PY' +import json +import sys +from 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()), + token={ + "private_key_jwt": { + "token_endpoint": "http://127.0.0.1:8081/token", + "client_id": "age-checker", + "client_key": json.loads( + Path(".evidence/clients/age-checker/private.jwk").read_text() + ), + }, + }, +) +print(json.dumps(client.discover(), indent=2, sort_keys=True)) +PY +``` + +```json +{ + "assuranceProfile": "local", + "configurationRevision": "sha256:", + "definitions": [ + { + "concepts": [ + { + "form": "boolean", + "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult" + } + ], + "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. Copy the values you need +into your code now. A request must never take an expectation from a discovery response fetched +alongside it. + +Four values in the procedure below do not come from discovery: + +- `audience` is the application's own registered identifier, from its client registration. +- `subject_expectations` is what the application already knows about the subject. +- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are the application's own bounds on + how stale an answer it accepts. + +`configurationRevision` is worth pinning deliberately. When the deployment's governed configuration +changes, the revision changes with it, verification fails until the procedure has been reviewed, and +your application refuses rather than silently accepting a different question's answer. + +## Write the relying procedure + +Open `age_check.py` in your editor and add the application. Substitute the +`configuration_revision` value from your own discovery output: + +```python +import json +import sys +from pathlib import Path + +sys.path.insert(0, "python-module") + +from registry_evidence_client import ( + DeniedError, + EvidenceClient, + EvidenceClientError, + NotAvailableError, + VerificationError, +) + +# Read once from the published definitions while writing this procedure. A +# request never takes an expectation from a fresh discovery response. +PROCEDURE = { + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "audience": "urn:registrystack:evidence:local:client:age-checker", + "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", + "issued_by": "urn:registrystack:evidence:local:issuer", + "provided_by": "urn:registrystack:evidence:local:provider", + "configuration_revision": "sha256:", + "expected_outputs": [ + { + "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean", + }, + ], + "expected_assurance_profile": "local", + "maximum_assertion_lifetime_seconds": 300, + "clock_skew_seconds": 30, +} +IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult" +SELECTOR_PROFILE = "local-subject-adult-status-v1" +BINDINGS = Path("subject-bindings.json") + + +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()), + token={ + "private_key_jwt": { + "token_endpoint": "http://127.0.0.1:8081/token", + "client_id": "age-checker", + "client_key": json.loads( + Path(".evidence/clients/age-checker/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): + store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} + store[person_id] = pinned + BINDINGS.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") + + +def ask_is_adult(client, person_id): + spec = dict( + PROCEDURE, + subjects=[ + { + "role": "person", + "selector_profile": 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}") +``` + +Four properties of that code are the point of this tutorial: + +- `prepare` performs no network call. It closes the request, 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_verify` returns 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 `EvidenceClientError` catches the rest. Every path exits. + Nothing falls through to a default answer. + +## Run it + +The recorded subject binding is scoped to this audience and purpose, so keep it owner-only: + +```sh +umask 077 +python3 age_check.py +``` + +```text +person-123 is_adult=True +pinned binding recorded in subject-bindings.json +``` + +The first run had nothing to pin, so it accepted the binding on first use and recorded it. Run it +again: + +```sh +python3 age_check.py +``` + +```text +person-123 is_adult=True +pinned binding recorded in subject-bindings.json +``` + +The second run pinned the recorded binding, and the response had to carry that exact value. The +binding is stable for the same subject, audience, and purpose, and unrelated for any other audience +or purpose. 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. + +Ask about a different record: + +```sh +python3 age_check.py person-456 +``` + +```text +person-456 is_adult=False +pinned binding recorded in subject-bindings.json +``` + +The registry holds a name and a date of birth for both people. Neither answer contains either. + +## Refuse before reading + +Change one stored binding to prove that the application, not the deployment, decides what it +accepts: + +```sh +python3 - <<'PY' +import json +from 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") +PY +python3 age_check.py person-123 +``` + +```text +unverifiable response, nothing read (policy): the Evidence response failed verification: Evidence payload does not match the relying procedure +``` + +Evidence 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. A stale +`configuration_revision` 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`. HTTP 401, 403, and 429 +all map to `denied`, whatever the response body's own code says, and every other non-2xx status maps +to `protocol`. 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 + +```sh +evidencectl dev stop +evidencectl dev clean +``` + +```text +Local Evidence stopped +Removed stopped local Evidence state +``` + +This keeps `age_check.py`, 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`. + +## Next + +- [Verify Evidence as a consumer](../verify-an-assertion-as-a-consumer/), for re-verifying a stored + response at the recorded decision time +- [Control who can request Evidence](../control-who-can-request-evidence/), for policies and + revocation +- [Request an access token from your own code](../../configure/request-an-access-token/), for the + token exchange without the client library +- [Review the Evidence Gateway API](../../reference/apis/registry-evidence/) From 033f7036bc527b0202dbc6ea3b608c1a44a57fab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 10:43:15 +0700 Subject: [PATCH 2/3] docs(evidence): pin the relying procedure from discovery Copying a 64-hex configuration revision into source by hand is a poor first experience and invites the reader to keep re-copying it whenever verification starts failing. Save the discovery document instead, transform it offline into a procedure.json the application owns, and load that file at startup. The pinning act stays explicit and still happens before any answer is read; it is only automated instead of transcribed. Regenerating is documented as a review step, with the diff to look at, not a retry. expected_outputs stays hand-written: a concept's published form and a verification expectation's form are separate vocabularies, so deriving one from the other would work for this requirement and mislead on the next. The generator checks the concept identifiers against discovery so a deployment that stops publishing one fails at review time. Signed-off-by: Jeremi Joslin --- .../request-evidence-from-an-application.mdx | 167 ++++++++++++++---- 1 file changed, 128 insertions(+), 39 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 02f3ceb00..876b7e374 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -176,7 +176,8 @@ evidencectl: the active project requires a registered client selected with --cli ## Read the definitions once -Ask the deployment which complete request shapes this client may send: +Ask the deployment which complete request shapes this client may send, and keep the answer to +review: ```sh python3 - <<'PY' @@ -201,7 +202,9 @@ client = EvidenceClient( }, }, ) -print(json.dumps(client.discover(), indent=2, sort_keys=True)) +document = json.dumps(client.discover(), indent=2, sort_keys=True) +Path("discovery.json").write_text(document + "\n") +print(document) PY ``` @@ -251,25 +254,125 @@ PY ``` 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. Copy the values you need -into your code now. A request must never take an expectation from a discovery response fetched -alongside it. +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. -Four values in the procedure below do not come from discovery: +Read it here once, to author the procedure. From now on the procedure supplies every request's +expectations. -- `audience` is the application's own registered identifier, from its client registration. -- `subject_expectations` is what the application already knows about the subject. -- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are the application's own bounds on - how stale an answer it accepts. +## Pin the procedure -`configurationRevision` is worth pinning deliberately. When the deployment's governed configuration -changes, the revision changes with it, verification fails until the procedure has been reviewed, and -your application refuses rather than silently accepting a different question's answer. +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: + +```sh +python3 - <<'PY' +import json +import sys +from 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-checker" +EXPECTED_OUTPUTS = [ + { + "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean", + }, +] +MAXIMUM_LIFETIME_SECONDS = 300 +CLOCK_SKEW_SECONDS = 30 + +published = json.loads(Path("discovery.json").read_text()) +definition = next( + (item for item in published["definitions"] if item["requirement"] == REQUIREMENT), + None, +) +if definition is None: + sys.exit(f"this client may not request {REQUIREMENT}") + +# Fail here, at review time, rather than at verification time. +offered = {concept["id"] for concept in definition["concepts"]} +absent = [item["concept"] for item in EXPECTED_OUTPUTS if item["concept"] not in offered] +if absent: + sys.exit(f"the deployment no longer publishes {', '.join(absent)}") + +document = json.dumps( + { + "requirement": definition["requirement"], + "purpose": definition["purpose"], + "evidence_type": definition["evidenceType"], + "issued_by": published["issuedBy"], + "provided_by": published["providedBy"], + "configuration_revision": published["configurationRevision"], + "expected_assurance_profile": published["assuranceProfile"], + "audience": AUDIENCE, + "expected_outputs": EXPECTED_OUTPUTS, + "maximum_assertion_lifetime_seconds": MAXIMUM_LIFETIME_SECONDS, + "clock_skew_seconds": CLOCK_SKEW_SECONDS, + }, + indent=2, + sort_keys=True, +) +Path("procedure.json").write_text(document + "\n") +print(document) +PY +``` + +```json +{ + "audience": "urn:registrystack:evidence:local:client:age-checker", + "clock_skew_seconds": 30, + "configuration_revision": "sha256:", + "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", + "purpose": "age-check", + "requirement": "urn:registrystack:evidence:local:requirement:adult-status" +} +``` + +`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. + +Three of those values are the application's own judgement, and no deployment can supply them: + +- `audience` is the identifier the client registration assigned this application. +- `maximum_assertion_lifetime_seconds` and `clock_skew_seconds` are 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 identifiers are +still checked against discovery above, so a deployment that stops publishing one fails at review +time rather than at verification time. + +`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 the deployment's entire +governed configuration, so it moves whenever an operator changes any of it, and verification then +fails until someone has looked at what changed. When that happens, keep the reviewed copy, write a +new one, and `diff` them before accepting: a changed revision alone is routine, a changed +`evidence_type`, `issued_by`, or concept set means the question itself moved. ## Write the relying procedure -Open `age_check.py` in your editor and add the application. Substitute the -`configuration_revision` value from your own discovery output: +Open `age_check.py` in your editor and add the application. It loads the pinned procedure and never +calls discovery again: ```python import json @@ -286,26 +389,9 @@ from registry_evidence_client import ( VerificationError, ) -# Read once from the published definitions while writing this procedure. A -# request never takes an expectation from a fresh discovery response. -PROCEDURE = { - "requirement": "urn:registrystack:evidence:local:requirement:adult-status", - "purpose": "age-check", - "audience": "urn:registrystack:evidence:local:client:age-checker", - "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", - "issued_by": "urn:registrystack:evidence:local:issuer", - "provided_by": "urn:registrystack:evidence:local:provider", - "configuration_revision": "sha256:", - "expected_outputs": [ - { - "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", - "form": "boolean", - }, - ], - "expected_assurance_profile": "local", - "maximum_assertion_lifetime_seconds": 300, - "clock_skew_seconds": 30, -} +# 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()) IS_ADULT = "urn:registrystack:evidence:local:concept:adult-status:is_adult" SELECTOR_PROFILE = "local-subject-adult-status-v1" BINDINGS = Path("subject-bindings.json") @@ -378,8 +464,10 @@ print(f"{person_id} is_adult={is_adult}") print(f"pinned binding recorded in {BINDINGS}") ``` -Four properties of that code are the point of this tutorial: +Five properties of that code are the point of this tutorial: +- Every expectation comes from `procedure.json`. The program holds no discovery client and cannot + learn what to expect from the deployment it is checking. - `prepare` performs no network call. It closes the request, 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_verify` returns only after the response satisfied the policy. There is no object in @@ -455,8 +543,8 @@ unverifiable response, nothing read (policy): the Evidence response failed verif ``` Evidence 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. A stale -`configuration_revision` fails the same way, and for the same reason. +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. @@ -494,7 +582,8 @@ Local Evidence stopped Removed stopped local Evidence state ``` -This keeps `age_check.py`, the pinned JWKS, the recorded bindings, and the client's private key. +This 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`. ## Next From 4fb65fdbb741b7031bfff0789a34ab4a31c37f3e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 6 Aug 2026 14:20:55 +0700 Subject: [PATCH 3/3] docs(evidence): correct the application tutorial's ids and claims Review surfaced eight defects, each confirmed against source: - the policy and client ids collided with the access-control tutorial, so authoring refused for a reader who had followed the other page; both pages now carry their own ids and this one says why they compose - the source clone was unpinned, so a reader could build the client from a tree that need not match the installed evidencectl; it now pins the tag of the installed version - the review step accepted a subset of the published concepts, which the verifier rejects at request time; it now requires the exact set - the application hard-coded the selector profile instead of reading the shape it had reviewed - the binding-stability prose named only subject and audience, while the MAC also covers purpose, role, selector profile, and the deployment's binding key and key version - the bindings store dropped a concurrent run's entry, and its replacement stays owner-only whatever umask the shell carries - the page never restarted the registry the prerequisite leaves stopped, and evidencectl dev reports ready without reaching the source - the error-mapping prose implied any body code maps, where the contract honors a code only under a status registered for it Verified by replaying all thirty-one fences against binaries built from this checkout: every documented output matches apart from the deliberate revision placeholder. docs/site npm test and npm run check pass. Signed-off-by: Jeremi Joslin --- .../request-evidence-from-an-application.mdx | 225 +++++++++++++----- 1 file changed, 166 insertions(+), 59 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 876b7e374..a3652385e 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -27,7 +27,7 @@ program itself retained. level="Local development with synthetic data" prerequisites={[ 'The completed first Evidence assertion tutorial', - 'Its adult-status project and running registry.py', + 'Its adult-status project and its registry.py, which you restart here', 'Python 3.10 or later', 'A Rust toolchain and git, to build the client from source', ]} @@ -66,28 +66,34 @@ An application authenticates as a registered client, not as the project owner. D the question it may ask, then register the client: ```sh -evidencectl access policy add age-checks --question adult-status -evidencectl access client add age-checker \ - --policy age-checks \ +evidencectl access policy add app-age-checks --question adult-status +evidencectl access client add age-check-app \ + --policy app-age-checks \ --generate-local-key ``` ```text -Added access policy age-checks for adult-status. -Added client age-checker with policy age-checks. +Added access policy app-age-checks for adult-status. +Added client age-check-app with policy app-age-checks. ``` -The reviewable registration at `access/clients/age-checker.yaml` carries the policy membership and -the public key. The private key stays owner-only at `.evidence/clients/age-checker/private.jwk` and -is the application's identity. The registration also fixes the audience the application must state -for itself: +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](../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: ```sh -grep evidenceAudience access/clients/age-checker.yaml +grep evidenceAudience access/clients/age-check-app.yaml ``` ```text -evidenceAudience: urn:registrystack:evidence:local:client:age-checker +evidenceAudience: urn:registrystack:evidence:local:client:age-check-app ``` A project with no access policy has an unnamed development caller. Registering the first policy @@ -117,16 +123,25 @@ handling. ## 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. From inside the project directory: +the repository. Take the source at the version of the runtime you installed, not at the default +branch. From inside the project directory: ```sh -git clone --depth 1 https://github.com/registrystack/registry-stack.git ../registry-stack +installed="$(evidencectl --version | awk '{print $2}')" +git clone --depth 1 --branch "v$installed" \ + https://github.com/registrystack/registry-stack.git ../registry-stack cargo build --locked --manifest-path ../registry-stack/Cargo.toml \ -p registry-evidence-client-py --lib \ --features registry-evidence-client-py/extension-module ``` -If you already have a checkout, point the two `../registry-stack` paths at it instead of cloning. +This 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: @@ -145,8 +160,20 @@ configures itself against the interpreter it will be imported by. ## Start the local services -Compile the question and the access policy into a fresh generation, and start Evidence and Registry -Mint: +Evidence reads the source record through the `registry.py` server from the first tutorial, whose +cleanup told you to stop it. Start it again in its own terminal, from the project directory, and +leave it running: + +```sh +python3 registry.py +``` + +```text +Registry listening on http://127.0.0.1:8000 +``` + +Back in the first terminal, compile the question and the access policy into a fresh generation, and +start Evidence and Registry Mint: ```sh evidencectl dev --detach @@ -157,8 +184,8 @@ Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` -The `registry.py` server from the first tutorial must still be running in its own terminal. Evidence -reads the source record through it. +`evidencectl 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: @@ -195,9 +222,9 @@ client = EvidenceClient( token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", - "client_id": "age-checker", + "client_id": "age-check-app", "client_key": json.loads( - Path(".evidence/clients/age-checker/private.jwk").read_text() + Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, }, @@ -275,7 +302,7 @@ from 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-checker" +AUDIENCE = "urn:registrystack:evidence:local:client:age-check-app" EXPECTED_OUTPUTS = [ { "concept": "urn:registrystack:evidence:local:concept:adult-status:is_adult", @@ -286,18 +313,34 @@ MAXIMUM_LIFETIME_SECONDS = 300 CLOCK_SKEW_SECONDS = 30 published = json.loads(Path("discovery.json").read_text()) -definition = next( - (item for item in published["definitions"] if item["requirement"] == REQUIREMENT), - None, -) -if definition is None: - sys.exit(f"this client may not request {REQUIREMENT}") - -# Fail here, at review time, rather than at verification time. -offered = {concept["id"] for concept in definition["concepts"]} -absent = [item["concept"] for item in EXPECTED_OUTPUTS if item["concept"] not in offered] -if absent: - sys.exit(f"the deployment no longer publishes {', '.join(absent)}") +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( { @@ -312,6 +355,16 @@ document = json.dumps( "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, @@ -323,7 +376,7 @@ PY ```json { - "audience": "urn:registrystack:evidence:local:client:age-checker", + "audience": "urn:registrystack:evidence:local:client:age-check-app", "clock_skew_seconds": 30, "configuration_revision": "sha256:", "evidence_type": "urn:registrystack:evidence:local:evidence-type:adult-status", @@ -337,6 +390,18 @@ PY "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" } @@ -356,9 +421,17 @@ Three of those values are the application's own judgement, and no deployment can 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 identifiers are -still checked against discovery above, so a deployment that stops publishing one fails at review -time rather than at verification time. +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. @@ -366,8 +439,9 @@ already knows about the subject in front of it. Regenerating this file is a review step, not a retry. The revision covers the deployment's entire governed configuration, so it moves whenever an operator changes any of it, and verification then fails until someone has looked at what changed. When that happens, keep the reviewed copy, write a -new one, and `diff` them before accepting: a changed revision alone is routine, a changed -`evidence_type`, `issued_by`, or concept set means the question itself moved. +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 @@ -375,7 +449,9 @@ Open `age_check.py` in your editor and add the application. It loads the pinned calls discovery again: ```python +import fcntl import json +import os import sys from pathlib import Path @@ -392,9 +468,12 @@ from registry_evidence_client import ( # 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" -SELECTOR_PROFILE = "local-subject-adult-status-v1" BINDINGS = Path("subject-bindings.json") +BINDINGS_LOCK = Path("subject-bindings.lock") def build_client(): @@ -405,9 +484,9 @@ def build_client(): token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", - "client_id": "age-checker", + "client_id": "age-check-app", "client_key": json.loads( - Path(".evidence/clients/age-checker/private.jwk").read_text() + Path(".evidence/clients/age-check-app/private.jwk").read_text() ), }, }, @@ -422,9 +501,20 @@ def expectations_for(person_id): def remember(person_id, pinned): - store = json.loads(BINDINGS.read_text()) if BINDINGS.exists() else {} - store[person_id] = pinned - BINDINGS.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") + """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): @@ -432,8 +522,8 @@ def ask_is_adult(client, person_id): PROCEDURE, subjects=[ { - "role": "person", - "selector_profile": SELECTOR_PROFILE, + "role": SUBJECT["role"], + "selector_profile": SUBJECT["selector_profile"], "selector_values": {"person_id": person_id}, }, ], @@ -464,10 +554,10 @@ print(f"{person_id} is_adult={is_adult}") print(f"pinned binding recorded in {BINDINGS}") ``` -Five properties of that code are the point of this tutorial: +Six properties of that code are the point of this tutorial: -- Every expectation comes from `procedure.json`. The program holds no discovery client and cannot - learn what to expect from the deployment it is checking. +- 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. - `prepare` performs no network call. It closes the request, 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_verify` returns only after the response satisfied the policy. There is no object in @@ -476,6 +566,10 @@ Five properties of that code are the point of this tutorial: response carrying different values cannot be misread as this one. - Three failures get their own branch, and `EvidenceClientError` catches the rest. Every path exits. Nothing falls through to a default answer. +- `remember` reads 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 @@ -503,10 +597,19 @@ person-123 is_adult=True pinned binding recorded in subject-bindings.json ``` -The second run pinned the recorded binding, and the response had to carry that exact value. The -binding is stable for the same subject, audience, and purpose, and unrelated for any other audience -or purpose. 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 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: @@ -561,10 +664,14 @@ Branch on the exception class or on `kind`, never on the message text, which is | `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`. HTTP 401, 403, and 429 -all map to `denied`, whatever the response body's own code says, and every other non-2xx status maps -to `protocol`. No exception carries response bytes, a credential, a header value, a selector value, -or a subject binding. +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