Skip to content
Registry StackDocsDevelopment (unreleased)

Get your first Evidence Gateway assertion

For the assertion provider

View as Markdown

Evidence Gateway reads an authorized source record, answers a preconfigured question, and signs only the governed answer. In this tutorial, you will ask “Is this person an adult?” without returning the person’s name or date of birth.

Outcome
One governed adult-status answer verified as signed JWS and SD-JWT VC, without the source record.
Time
About 20 minutes
Level
Local development with synthetic data
Prerequisites
Python 3A shell with curlAn editorLinux or macOS
%%{init: {"sequence": {"mirrorActors": false}}}%%
sequenceDiagram
    participant C as Tutorial caller
    participant E as Evidence Gateway
    participant R as Tutorial registry

    C->>E: Authorized request<br/>adult-status, person_id=person-123
    E->>R: GET /people/person-123
    R-->>E: person_id, name, date_of_birth
    E->>E: Extract date_of_birth<br/>Derive and sign is_adult: true
    E-->>C: Signed assertion with is_adult: true<br/>and an opaque subject binding
    Note over C,E: The assertion contains no person_id,<br/>name, or date_of_birth

Before the request, Registry Mint gives the tutorial caller short-lived local authorization. Evidence Gateway and the registry process the identifier and date of birth to answer the question, but the released assertion contains only the governed answer and an opaque subject binding.

Install the latest Evidence Gateway toolset:

Terminal window
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/evidencectl-install.sh | bash
evidencectl --version

The installer provides evidencectl and the local Evidence Gateway and Registry Mint runtimes used later in the tutorial.

Create a working directory:

Terminal window
mkdir first-evidence-assertion
cd first-evidence-assertion

Open registry.py in your editor and add this server:

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlsplit
PEOPLE = {
"person-123": {
"person_id": "person-123",
"name": "Amina Example",
"date_of_birth": "2000-01-01",
},
"person-456": {
"person_id": "person-456",
"name": "Mateo Example",
"date_of_birth": "2012-05-20",
},
"person-789": {
"person_id": "person-789",
"name": "Noor Example",
"date_of_birth": "1988-11-03",
},
}
OPENAPI = {
"openapi": "3.1.0",
"info": {"title": "Tutorial registry", "version": "1.0.0"},
"servers": [{"url": "http://127.0.0.1:8000"}],
"paths": {"/people/{person_id}": {"get": {
"operationId": "getPerson",
"parameters": [{"name": "person_id", "in": "path", "required": True,
"schema": {"type": "string"}}],
"responses": {"200": {
"description": "A person record",
"content": {"application/json": {
"schema": {
"type": "object",
"required": ["person_id", "name", "date_of_birth"],
"properties": {
"person_id": {"type": "string", "minLength": 1, "maxLength": 64},
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"date_of_birth": {
"type": "string", "format": "date",
"minLength": 10, "maxLength": 10,
},
},
},
}},
}},
}}},
}
class Registry(BaseHTTPRequestHandler):
def do_GET(self):
path = unquote(urlsplit(self.path).path)
if path == "/openapi.json":
return self.send_json(OPENAPI)
people_prefix = "/people/"
if path.startswith(people_prefix):
person = PEOPLE.get(path[len(people_prefix):])
if person is not None:
return self.send_json(person)
self.send_error(404)
def send_json(self, value):
body = json.dumps(value).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
print("Registry listening on http://127.0.0.1:8000", flush=True)
ThreadingHTTPServer(("127.0.0.1", 8000), Registry).serve_forever()

You do not need to understand every line of the server. Three details connect it to Evidence Gateway:

  • /people/{person_id} returns a complete person record.
  • /openapi.json describes that endpoint and its response.
  • getPerson is the OpenAPI operation that the Evidence Gateway question will select.

In one terminal, start the registry and leave it running:

Terminal window
python3 registry.py

The server publishes its OpenAPI description at /openapi.json and three synthetic records: person-123, person-456, and person-789. The records include both adults and a child, but this tutorial will request an assertion only for person-123.

In another terminal, return to first-evidence-assertion and inspect the source record:

Terminal window
curl -s http://127.0.0.1:8000/people/person-123 | python3 -m json.tool
{
"person_id": "person-123",
"name": "Amina Example",
"date_of_birth": "2000-01-01"
}

The registry returns an identifier, a name, and a date of birth. The verified assertion will contain none of those source values.

Create a local project from the registry’s OpenAPI description:

Terminal window
evidencectl new adult-status \
--openapi http://127.0.0.1:8000/openapi.json \
--profile local
cd adult-status

--profile local creates a loopback-only development project, not a production deployment. The command creates owner-only local keys for signing, subject binding, and audit integrity. OpenAPI describes the source, but you still decide the question and what the answer may disclose.

The new project separates those decisions from the retained API description:

adult-status/
├── source.openapi.yaml
├── selectors/
├── sources/
├── adapters/
├── schemas/
├── questions/
├── derivations/
└── secrets/

This first question uses the compact OpenAPI form, so you will add only a question and its derivation. Later institution integrations use the reusable source, selector, adapter, and schema directories.

Open questions/adult-status.yaml in your editor and add the question definition:

id: adult-status
question: Is the person at least 18 years old?
purpose: age-check
subject:
role: person
selector: person_id
source:
operation: getPerson
facts:
- name: date_of_birth
path: /date_of_birth
combine: exactly-one
collectionBounds: {}
answers:
- concept: is_adult
type: boolean
responseFormats: [signed-jws, sd-jwt-vc]
derivation: derivations/adult-status.rhai
disclosure:
allow: [is_adult]

The definition closes four decisions:

  • subject says that person_id selects the person in /people/{person_id}.
  • source selects getPerson and allows only date_of_birth to reach the derivation. It does not change the complete record returned by the registry.
  • answers declares one permitted result, a boolean named is_adult.
  • responseFormats keeps signed JWS as the default and also permits the same answer to be returned as SD-JWT VC.
  • disclosure is the explicit release boundary. It must name exactly the answers returned by the derivation.

combine: exactly-one rejects a missing or repeated date_of_birth. collectionBounds: {} is empty because this fact does not traverse a collection. The request purpose must be age-check, matching the purpose declared here.

Open derivations/adult-status.rhai in your editor and add the answer logic. Rhai is the bounded scripting language Evidence Gateway uses for requirement-specific derivations:

fn answer(facts, selectors, context) {
let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing"));
let adult_on = add_calendar_years(born, 18);
#{is_adult: compare_dates(context.legal_local_date, adult_on) >= 0}
}

The derivation performs four operations:

  1. Require and parse the selected date of birth.
  2. Calculate the eighteenth birthday using calendar arithmetic.
  3. Compare it with context.legal_local_date, which Evidence Gateway derives from the observation instant in the configured timezone.
  4. Return a map containing exactly the declared is_adult answer.

Evidence Gateway validates the returned name and boolean form before signing.

Start Evidence Gateway and Registry Mint:

Terminal window
evidencectl dev --detach
Evidence ready at http://127.0.0.1:8080
Mint ready at http://127.0.0.1:8081

This command starts Evidence Gateway and Mint only. The Python registry remains the process you started in the first terminal.

Prepare the request, local access, and verification expectations:

Terminal window
evidencectl request prepare adult-status \
--purpose age-check \
--subject person_id=person-123 \
--name first-assertion

The Evidence client prepares the request and closes its pinned verification expectations locally, before a response exists. evidencectl separately asks the local Mint for short-lived authorization. It sends no HTTP request to Evidence Gateway and does not contact the registry. The command creates exactly these owner-only artifacts:

Prepared request: .evidence/requests/first-assertion/request.json
Prepared verification context: .evidence/requests/first-assertion/verification.json
Prepared authorization: .evidence/requests/first-assertion/authorization.curl

The artifacts have separate responsibilities:

  • request.json contains the question, purpose, fresh request nonce, and person-123 selector.
  • verification.json records what a valid response must satisfy before the response exists.
  • authorization.curl contains only the short-lived local authorization header.

Send the request yourself:

Terminal window
curl --silent --show-error --fail-with-body \
--config .evidence/requests/first-assertion/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/jose+json' \
--data-binary @.evidence/requests/first-assertion/request.json \
--output assertion.jws.json \
--write-out 'HTTP %{http_code}\n'
HTTP 200

curl made the one POST /v1/evidence assertion request, Evidence Gateway called the registry, and the HTTP response is a signed flattened JSON Web Signature (JWS). A successful HTTP response is not yet a trusted assertion. Preparation made zero Evidence Gateway HTTP requests, and the verification step below is offline, so one assertion requires one request to Evidence Gateway rather than two.

Verify the response against the expectations recorded before the request. Verification checks the signature and exact issuer, question, purpose, audience, request nonce, subject binding, answer shape, and validity period:

Terminal window
evidencectl verify assertion.jws.json \
--context .evidence/requests/first-assertion/verification.json \
--output verified.json
VERIFIED

Only after the command prints VERIFIED, inspect the verified payload:

Terminal window
python3 -m json.tool verified.json

The verified document contains generated identifiers, timestamps, and a pseudonymous subject binding. The fields relevant to this question look like this excerpt:

{
"assuranceProfile": "local",
"purpose": "age-check",
"subjects": [
{
"binding": "urn:evidence:subject:v1_…",
"role": "person"
}
],
"supportedValues": [
{
"providesValueFor": "urn:registrystack:evidence:local:concept:adult-status:is_adult",
"value": true
}
],
"supportsRequirement": "urn:registrystack:evidence:local:requirement:adult-status"
}

supportsRequirement names the requirement that the question you authored under questions/ compiles to, which is the unit the deployed configuration and the audit record.

The verified payload does not include person_id, name, or date_of_birth. The assertion answers the authored question without becoming another copy of the registry record.

The owner-only request.json still records that this transaction concerned person-123, while verification.json records the expected opaque subject binding and request nonce. Retain those files with the signed response when you need to verify Evidence Gateway later as a consumer. The identifier cannot be recovered from the assertion alone.

Prepare a second request for the same governed question, this time recording SD-JWT VC as the expected response format:

Terminal window
evidencectl request prepare adult-status \
--purpose age-check \
--subject person_id=person-123 \
--format sd-jwt-vc \
--name first-vc
Prepared request: .evidence/requests/first-vc/request.json
Prepared verification context: .evidence/requests/first-vc/verification.json
Prepared authorization: .evidence/requests/first-vc/authorization.curl

Send the request with the exact SD-JWT VC media type:

Terminal window
curl --silent --show-error --fail-with-body \
--config .evidence/requests/first-vc/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/dc+sd-jwt' \
--data-binary @.evidence/requests/first-vc/request.json \
--output assertion.sd-jwt \
--write-out 'HTTP %{http_code}\n'
HTTP 200

Treat the compact credential as opaque until verification succeeds:

Terminal window
evidencectl verify assertion.sd-jwt \
--context .evidence/requests/first-vc/verification.json \
--output verified-vc.json
VERIFIED

Now inspect the verified Evidence payload:

Terminal window
python3 -m json.tool verified-vc.json

It has the same governed is_adult: true answer and minimum-disclosure boundary as the signed JWS response. Only the serialization changed. This is a stateless credential response, not a wallet issuance, revocation, or presentation workflow.

Continue with Explore SD-JWT VC locally to inspect disclosures, issuer metadata, tamper refusal, and structured-field projection.

Stop Evidence Gateway and Mint before reading the completed audit chain:

Terminal window
evidencectl dev stop
Local Evidence stopped

Verify the completed audit chain and show its last operation:

Terminal window
evidencectl audit show --last-operation
ACCESS AUTHORIZED adult-status age-check requester=<pseudonym>
DISCLOSURE RELEASED is_adult

The requester value changes on each fresh project. The view identifies the question, purpose, requester pseudonym, authorization decision, and disclosed concept. It does not repeat the source record or access token.

Remove the stopped local generation, including the sealed bundle that Evidence Gateway used:

Terminal window
evidencectl dev clean
Removed stopped local Evidence state

This preserves your editable questions, derivations, keys, and request artifacts. It removes .evidence/dev, whose runtime files are deliberately read-only while a generation exists. Run dev stop and then dev clean instead of changing those permissions by hand. The command refuses to remove a running or unrecognized generation.

Return to the first terminal and press Ctrl+C to stop the registry.

Keep the tutorial directory to continue with the governed-value tutorial. If you do not plan to continue, you can remove the complete directory with ordinary file commands.

Choose two different unused ports when you start the local services:

Terminal window
evidencectl dev --detach --evidence-port 8180 --mint-port 8181

Evidence Gateway uses the selected ports consistently in its runtime, Mint authorization, readiness checks, and generated request artifacts. Send the assertion request to the selected Evidence Gateway port, such as http://127.0.0.1:8180/v1/evidence.

The development profile binds both services to loopback in the environment where the command runs. If that environment is a Docker container, run the tutorial’s curl commands in the same container. Use the operator deployment configuration when the service must listen beyond loopback.