Run Guide¶
This guide covers the public-safe PoC paths in this repository.
Prerequisites¶
- Python
3.12+ uv- Node.js
22+ - Docker with Compose support for the containerized stack
- Xcode for the native iPhone scanner app
Environment Setup¶
From backend:
This creates or updates .venv using pyproject.toml and uv.lock.
From frontend:
This creates the React 19 / Vite 8 / Tailwind v4 frontend workspace used for the replacement verifier workbench.
Test Suite¶
From backend:
Coverage includes:
- unit tests for signed schema, payload revalidation, and the residual vector
- API tests for
/verifier/* - QR artifact tests for PNG render, decode, and scanned verification
- optional Playwright browser regression for the legacy backend-served lab
- live HTTP integration coverage when socket binding is available
- CI and release review rerun the backend, frontend, Compose, navigation, and release-boundary gates; do not infer current results from a historical count
Frontend validation:
From frontend:
Current baseline:
- TypeScript build succeeds
- Vite production bundle succeeds
- ESLint passes on the frontend workspace
Documentation Site¶
The repository uses MkDocs for a filtered, locally browsable technical site. From the repository root:
Open http://127.0.0.1:8088/. docs-build runs MkDocs in strict mode.
docs-serve serves only the generated site/ directory, rather than exposing
the maintainer workspace directly. The release exporter separately checks all
relative links against the complete public repository tree.
Rendered architecture and decision diagrams open in an interactive modal.
Select a diagram, then use the mouse wheel or +/- to zoom, drag to pan,
press 0 to fit, 1 for actual size, and Esc to close.
Links from the documentation to implementation files open generated, read-only source views. The build creates those pages only for files referenced by rendered documentation and only within the public source boundary; it never serves the maintainer workspace directly.
Native iPhone app build validation:
From the repository root:
Current baseline:
- the SwiftUI iPhone scanner compiles for the iOS Simulator SDK
- the app includes native QR camera capture, scanner-decision submission, end-user green/orange/red results, local history, Learn, Settings, and immediate haptic/audio feedback
make smoke-ioschecks that developer-only verifier/admin/demo surfaces stay out of the end-user app
Demo Scripts¶
From backend:
./.venv/bin/python scripts/payload_revalidation_poc_demo.py
./.venv/bin/python scripts/signed_schema_poc_demo.py
./.venv/bin/python scripts/narrowed_verifier_poc_demo.py
./.venv/bin/python scripts/qr_artifact_poc_demo.py
What each demo proves:
payload_revalidation_poc_demo.py- exact host match,
wwwnormalization, subdomain policy behavior, and issuer-state changes signed_schema_poc_demo.py- fixed claim order, deterministic serialization, signature verification, and metadata-conflict rejection
narrowed_verifier_poc_demo.py- full verifier chain across valid, expired, not-yet-valid, revoked, inactive-issuer, and destination-mismatch cases
qr_artifact_poc_demo.py- signed envelope -> QR PNG -> decoded scan payload -> verifier result
FastAPI Reference Surface¶
The canonical reference API is:
GET /verifier/statusGET /verifier/trust-storePOST /verifier/demo-materialsPOST /verifier/verifyPOST /verifier/verify-scannedPOST /verifier/decode-image
Notes:
POST /verifier/demo-materialsreturns demo certificate data, issuer state, a ready-to-submit verifier request, QR artifact data, and atrustecho (key_ref,key_state,issuer_status,retired_key_refs) describing the key the artifact was sealed under- the demo issuer keeps one process-stable signing key, so every demo QR
issued since the API started keeps verifying;
rotate_key: truemints a successor key and retires the previous one, andkey_state: "revoked"revokes the current key (terminal — the next plain call mints a fresh key) GET /verifier/trust-storelists the issuers and keys the scanned path trusts right now, with each key'sstate(active,retired,revoked) and validity window; it is read-only and gated like/statusevidence (open when verifier auth is disabled, otherwise a management credential)- the trust store is in-memory for this cycle: it resets when the API container restarts, and demo artifacts sealed before the restart stop verifying until they are regenerated
GET /verifier/statusreturns the current verifier posture for auth, rate limits, Redis-backed coordination, and decode fallback support- it does not return the signing private key
POST /verifier/decode-imagerejects oversized image payloads before decode- verifier POST routes are rate-limited per client, using Redis-backed coordination when Redis is available and in-memory fallback otherwise
- every signed QR code carries two scan-flood budgets that a flood spread across many source addresses cannot dodge: a per-envelope budget (
VERIFIER_ENVELOPE_RATE_LIMIT_MAX_REQUESTS, default 300 perVERIFIER_ENVELOPE_RATE_LIMIT_WINDOW_SECONDS, default 60) checked before the signature is verified, and a per-issuer budget (VERIFIER_ISSUER_RATE_LIMIT_MAX_REQUESTS, default 3000) that only signature-valid scans spend; an exhausted budget returns429withRetry-Afterand records no scanner evidence. The budget bounds how much evidence one envelope may accumulate; it is not a per-presentation gate, and a repeat presentation inside the validity window still verifies - behind a reverse proxy, set
FORWARDED_ALLOW_IPSto the proxy's address (uvicorn's own setting; the compose files default it to127.0.0.1) so the per-client limit keys on the real client address instead of the proxy's;GET /verifier/statusreportsforwarded_ip_trust_configured, which is true only when something beyond loopback is trusted - without Redis every limit is per-process and in-memory, so budgets do not add up across API replicas; the API logs a startup warning when it falls back
- verdicts are served from a short-lived cache: the first scan of an envelope in each window pays for the signature check, the budget spend and the evidence write, and identical scans inside the window get the same verdict back with
X-QR-Trust-Verdict: cached(computedotherwise) and no evidence row.VERIFIER_VERDICT_CACHE_TTL_SECONDS(default30) caps the window, the code's ownexpires_atcaps it further, and0disables the cache. Cached scans are still counted per code (a counter, not a row) so the workbench card stays honest; without Redis that counter, like the cache itself, is per API replica - the evidence store doubles as the scan ledger:
GET /admin/scan-accounting(management credential,audit:read) returns scans per issuer per UTC day with the green/orange/red split and adistinct_envelopescount, plus the envelopes currently spiking; a background monitor runs the same spike query everyVERIFIER_SCAN_SPIKE_INTERVAL_SECONDSand writes onescanner.spike.detectedoutbox event per envelope per baseline window when the lastVERIFIER_SCAN_SPIKE_WINDOW_SECONDShold at leastVERIFIER_SCAN_SPIKE_MIN_SCANSscans and at leastVERIFIER_SCAN_SPIKE_RATIOtimes the code's own per-window baseline overVERIFIER_SCAN_SPIKE_BASELINE_SECONDS(defaults60,60,30,10.0,3600); the monitor only runs withQRTRUST_NETWORK_DATABASE_URLset and reports itself asscan_spike_alerts_enabledon/verifier/status - at the edge, add a rate limit the application cannot see past: a Cloudflare or WAF rule of about 100 requests a minute per client address on
/verifier/*, with a challenge on bursts, absorbs the volumetric flood before it reaches the per-code budget - DB-backed verifier-client keys protect verifier POST routes; static
VERIFIER_API_KEYSrequire explicitVERIFIER_STATIC_API_KEYS_ENABLED=truelocal opt-in - verifier client keys are managed through audited
/admin/verifier-clients/api-keys/*endpoints; retired/verifier/admin/api-keys/*routes return410 Gone - responses include
X-Request-IDfor request tracing - the older
/certificates/*,/qrcodes/*, and/organizations/*experimental routes have been removed and return404
From backend:
Then open:
http://127.0.0.1:8000/http://127.0.0.1:8000/docs
The API is headless: GET / returns a JSON service descriptor, and the React
workbench in frontend is the interactive client. There are no
server-rendered HTML pages.
Choosing a validity window¶
Signed claims carry issued_at and expires_at and nothing else about
freshness. The scanner consults the artifact the same way on every
presentation inside that window, and the freshness residual family blocks it
past expires_at. Every presentation of one envelope is evaluated the same
way; the verifier keeps no per-presentation state.
So the window is the whole decision. Pick it by how long the code must stay valid, and re-issue rather than expect the verifier to remember anything:
- a code with a natural end — a campaign poster, an event week, a batch of
packaging — should carry that end as
expires_at - a hand-off worth little once used — a ticket, a voucher — should carry a short window, minutes rather than days, so a photographed code goes cold on its own
- signage that genuinely cannot carry a short expiry stays acceptable for as long as the window and the issuer's certificate both hold; the per-envelope budget, the verdict cache and the edge rate limit bound the volume, not the acceptance
The workbench's Validity window section seals the window. It defaults to 5
minutes ahead and offers an Expires at picker up to 30 days out; the
server rejects longer lifetimes with 422. The Expired credential
scenario seals an already-closed window whatever you pick. The step says how
long the next code will stay valid before you press Generate, and the
sealed card counts it down afterwards under its collapsed Code details
(see the Expires row below).
Scan accounting and spike alerts¶
Every computed verdict lands in qr_trust.scanner_decisions, and the two
operator views built on it share one query each, so what the ledger shows is
what the alert fires on:
GET /admin/scan-accounting?days=7&limit=200groups the evidence rows by issuer and UTC day (days=1is today so far; scans with no issuer on the envelope are reported underissuer_id: nullrather than dropped). Each row carriesscan_count, thegreen_count/orange_count/red_countsplit anddistinct_envelopes, which is the number a hosted deployment would meter or cap per issuer.- The same response lists
spikes: envelopes whose scans in the trailingspike_window_secondsclearspike_min_scansandspike_threshold_ratiotimes the code's own per-window baseline. A code with no history is a spike as soon as it clears the floor, because a fresh code being hammered is the flood case the design is for.
A warm verdict cache answers a repeat scan of the same payload before the
evidence write and before the per-envelope budget, so a flood against a cached
code leaves only a couple of evidence rows a minute. The detector therefore
merges two sources: the evidence rows and the cache's own per-minute counters
(verdict_rate:<fingerprint>:<minute> in Redis, or the in-process fallback
when Redis is off, which then counts per API replica). Each spike record
carries cached_recent_count and cached_baseline_count, so an operator can
see how much of a burst never touched the database. Issuer-day rows count
computed verdicts only: a cached hit is known by envelope, not by issuer and day,
and it costs the deployment a Redis read rather than a verdict.
python3 - <<'PY'
import json, urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8000/admin/scan-accounting?days=3",
headers={"X-Admin-Token": "local-lab-admin"},
)
body = json.load(urllib.request.urlopen(req))
for row in body["issuers"]:
print(row["day"], row["issuer_id"], row["scan_count"], row["distinct_envelopes"])
print("spiking:", [s["envelope_fingerprint"] for s in body["spikes"]])
PY
The endpoint never emits anything. Alerts come from a background monitor in
the API lifespan that runs the identical detector every
VERIFIER_SCAN_SPIKE_INTERVAL_SECONDS and inserts a scanner.spike.detected
row into qr_trust.event_outbox. The payload is the same shape as every
other outbox row -- an event envelope (type: scanner.spike.detected,
root_program_id from the envelope's evidence rows, artifact_hash over the
body) with the spike record above as the body -- because the NATS relay
validates that envelope, maps the type onto a subject it knows
(qrtrust.<root>.scanner.spike.detected.v1 in the QRTRUST_SCANNER_AUDIT
stream) and refuses anything else. A spike on unsigned payloads has no root
program to publish under, so it is logged and shown on
/admin/scan-accounting but never written to the outbox. The event
id is bucketed per envelope per baseline window, so repeated ticks, restarts and
extra API replicas collapse onto one row through the outbox's event_id
uniqueness, and the alert shows up through the same /admin/outbox path and
NATS relay as every other outbox event. The monitor needs
QRTRUST_NETWORK_DATABASE_URL; 0 for the interval disables it, and
/verifier/status reports scan_spike_alerts_enabled with the active knobs.
React Verifier Workbench¶
The primary interactive client lives in frontend.
From frontend:
By default, Vite proxies /verifier/* to:
If your backend is running without TLS instead, override the proxy target:
Use the non-TLS override only when the verifier API is actually listening on
port 8000. If another local app owns that port, Vite will proxy lab requests
to the wrong backend and errors such as Django/gunicorn 500 responses are not
verifier failures.
Then open:
http://127.0.0.1:5173/
Current React workbench scope:
- runtime status
- admin key issue / key list refresh
- scenario-based QR generation, including the two key-lifecycle scenarios:
Rotated signing key(verifies under the new key while a code sealed before the rotation still verifies under the retired key) andRevoked signing key(blocks at the issuer chain with causekey-revoked) - direct verifier pass against the current QR payload
- live camera capture with browser decode or bundled fallback decode
- QR image upload and decode
- scanned payload verification
- fullscreen QR display for second-screen testing
- A/B comparison: pair the current scenario with one that differs by a single trust layer, see that layer named, and load the paired case from the verdict
A/B comparison¶
The workbench can pair the current scenario (A) with a second one (B)
that differs from it by exactly one trust layer, so any change in the verdict
is attributable to one cause. Every scenario is the valid case with a single
layer perturbed, which is what makes the pairing meaningful.
- On step 1, open Compare against a second scenario and pick B. The pair is
written to the URL as
?compare=<scenario>, so a shared or reloaded link keeps it (for example/?scenario=payload-mismatch&compare=valid). - The comparison card lists the evidence layers in verification order (issuer, destination binding, redirect policy, freshness, runtime safety, verifier cache, artifact integrity) and highlights the changed layer. If two scenarios exercise the same layers it says so instead of inventing a difference.
- On step 4 the same card offers Load B: it generates the paired QR, then swaps A and B, so the verdict can flip between the two cases without going back to step 1.
make check-frontend-comparison pins every scenario's summarised layers to its
documented expectedOutcome.layer; the route smoke additionally asserts the
card renders and names Destination binding for the documented pairs.
Phone-scan feedback¶
When the sealed QR is on screen (step 2, or the full-screen display), the
workbench polls
GET /verifier/scan-activity?envelope_id=<64 hex> every 5 seconds and shows
what the verifier recorded for this envelope. The identifier is derived
from what was signed — sha256(canonical_claims + "." + signature) — so a
regenerated code is a different envelope with its own activity, and no
issued_at disambiguator is needed: the sealed issued_at is inside the hash.
POST /verifier/demo-materials returns the envelope_id the workbench polls
with. Reading scan activity never records a scan; only
POST /scanner/decisions does. The card shows:
- the QR frame itself: nothing is drawn on the code before the first scan
(a scan can come from a phone, a tablet, a laptop camera or the browser
lab, so the page does not guess). Once a scanner decision for the envelope
exists the frame glows in the verdict colour — green, amber or red — and a
moment later shows Scanned · verified / needs review / blocked
<time>in the same colour. If the evidence store cannot answer, a muted pill says so instead of implying "no scans yet"; Expires— a live countdown to the sealed claims'expires_at(in 4m 43s, thenexpired 2m 10s ago). It is computed from the signed claims on the page, not from the verifier. The window is the whole freshness input: inside it every presentation is evaluated the same way, and pastexpires_atthefreshnessfamily blocks the scan;Scans(total plus a verified / review / blocked breakdown),First scan(only once there is more than one scan),Last scan, andScanner(iPhone app,Web lab (simulated)for the browser lab's own simulated scan, orUnknown scanner);Verdict— what the phone was told for the latest scan, in the verdict colour: the decision state plusrisk <n>/100andhold to openwhen they apply. It is composed from the same recorded decision as the "observed decision" toast, so the two never disagree;Destination— what the scanner reported doing with that decision throughPOST /scanner/ux-events:Opened by the scanner,Cancelled by the scanner,Hold completed, not opened,Previewed, not opened, orNot reported by the scanner. UX events are kept in the verifier process's memory (not the evidence store), so this row says not reported rather than guessing when no event for the decision reached this verifier instance;Vouched by— the issuer and first verified domain the demo claims were signed for, shown only once a scan came back green, i.e. once the verifier has actually vouched for it;- a
Throttlerow read from the scan-flood state the verifier reports for that envelope — how many scans were answered from the verdict cache and how much of the per-envelope budget is left in the current window (3 cached · 297 of 300 scans left per minute). The budget bounds how much evidence one envelope may accumulate; a repeat presentation inside the validity window still verifies.
Everything on the card is read back from data the verifier actually holds; a row whose data is missing is omitted rather than filled in (there is no region/geo row, for example, because no scan records a location).
The feedback is read back from the scanner-decision evidence store, so it is
only reported when the verifier has one (QRTRUST_NETWORK_DATABASE_URL; the
Compose stack configures it). Without one, or while the store is unreachable,
the card says Scan feedback unavailable and explains why — it never shows
None yet for scans it cannot see. Lookups are keyed by a fingerprint of the
the envelope identifier (scanner_decisions.envelope_fingerprint, migration
0008; the first 16 hex characters of envelope_id), never the full
identifier, and the endpoint is subject to the verifier API key and rate limit
like every other verifier read.
Optional local HTTPS for Safari or other secure-context camera testing:
FRONTEND_TLS_ENABLED=true \
FRONTEND_TLS_CERT_FILE=../local/https/verifier-lab.pem \
FRONTEND_TLS_KEY_FILE=../local/https/verifier-lab-key.pem \
VITE_BACKEND_TARGET=https://127.0.0.1:8444 \
npm run dev -- --host 0.0.0.0 --port 5173
Native iPhone Scanner¶
The native iPhone scanner app lives in ios/VerifierLabApp.
Use it when you need:
- immediate native feedback for blocked states such as
payload_revalidation - real camera behavior on iPhone without Safari/browser quirks
- an end-user scanner surface for QR decisions produced by the live verifier API
Open in Xcode:
Local developer endpoint configuration:
- simulator:
http://127.0.0.1:8000 - physical iPhone: generate the ignored Xcode provider profile with
make ios-provider-config
The physical-device endpoint is a developer-side local lab setting. It is not shown as an end-user app field, because production scanner users should receive managed verifier providers rather than pasted raw endpoints.
For a real iPhone on the same Wi-Fi, start the backend with local TLS and admin tokens:
If another local stack already owns the default Postgres or Redis host ports, keep this stack isolated by moving only the host-side DB and Redis bindings:
The HTTPS stack publishes the React workbench at https://<host>:8443 and the
API at https://127.0.0.1:8444. The :8443 URL the iPhone app dials is the
workbench origin, which proxies /verifier/* and /scanner/* to the API.
If you want to reuse an existing local Postgres/Redis stack, use:
This uses the existing Postgres user publisher, creates or reuses database
qr_trust_poc, and keeps its verdict cache, per-envelope scan budgets and
request rate limits in Redis DB 5.
Compose applies backend Alembic migrations before the API starts, including the
QR Trust reference tables used by /admin/outbox, /admin/audit, scanner
decision evidence, runtime observations, and event-outbox propagation. The
TypeScript network schema helper remains useful for shared-infra drift checks
and local smoke drills, but compose no longer depends on it as the normal source
of schema truth.
When the shared-infra or compose network database is configured through
QRTRUST_NETWORK_DATABASE_URL, GET /verifier/status also exposes
operator-visible evidence panels for:
- Postgres event-outbox propagation health
- runtime-safety observations written by provider integrations
These panels are read-only. They help operators see propagation and present-time destination-safety posture without making the FastAPI verifier own NATS workers or runtime-provider ingestion.
For production-style management inspection, use the management API through the
CLI. These commands read Postgres-owned state through /admin/*; they do not
read from NATS or mutate trust state.
For the local HTTPS stack started above, set the published API port and local
self-signed TLS mode once in the shell before running host-side qrtrustctl
commands:
Without those variables, host-side qrtrustctl defaults to the plain local
Compose API at http://127.0.0.1:8000. You can always override discovery with
QRTRUSTCTL_BASE_URL or --base-url.
local-lab-admin is the local bootstrap credential used by this guide. The
same X-Admin-Token header can carry an active DB-backed management API key
from qr_trust.management_api_keys; /admin/* endpoints enforce the key's
scopes and write its key id into audit rows.
Issue a scoped management key when you want an operator credential that is audited and revocable without keeping the bootstrap token in regular use. The plaintext key is returned only once by the issue command; later list and audit views expose metadata only:
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
management-key-issue \
--label "demo audit operator" \
--scope audit:read \
--scope outbox:read
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
management-key-list
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
management-key-revoke \
--key-id mkey_example
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
outbox-status
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
audit-list
Use audited outbox remediation when a stale or malformed event should stop retrying or when a failed event should be retried after fixing its dependency:
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
outbox-event-remediate \
--event-id evt_mgmt_stale_policy \
--action quarantine \
--reason "stale source policy event"
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
outbox-event-remediate \
--event-id evt_mgmt_stale_policy \
--action retry
Published outbox events are immutable. If delivered trust state was wrong, publish a correcting governance event instead of using remediation to requeue the published row.
Run the production-management live drill when you want one command to exercise
the managed workflow boundary. It proves that destination-policy publication
fails before issuer/domain preconditions, management mutations are idempotent,
issuer enrollment can reach destination-policy publication, governance and
runtime NATS subscribers are authorized through managed allowlists before broad
subject grants are rejected, verifier client keys cannot authenticate to
/admin/*, and an optional failed outbox event can be requeued after a
broker-outage repair:
python backend/scripts/qrtrustctl.py \
--base-url https://127.0.0.1:8444 \
--admin-token local-lab-admin \
--insecure-tls \
management-live-drill \
--idempotency-prefix local-management-drill
python backend/scripts/qrtrustctl.py \
--base-url https://127.0.0.1:8444 \
--admin-token local-lab-admin \
--insecure-tls \
management-live-drill \
--idempotency-prefix local-management-drill \
--outbox-retry-event-id evt_mgmt_failed_after_broker_outage \
--require-outbox-retry
Use --insecure-tls only for local self-signed HTTPS endpoints. Production
operator commands should rely on normal certificate verification.
Use --require-outbox-retry for evidence runs that must prove broker-outage
recovery; the drill fails if no failed outbox event is supplied.
Use the reset-guarded live outbox retry drill when you need the lower-level
Postgres-to-NATS recovery proof. It creates pending authority outbox rows,
marks them failed under a simulated broker outage, reconnects to local NATS, and
asserts the same rows publish after recovery. Because this drill resets
qr_trust, reset Make targets reapply backend Alembic afterward so local
shared DBs return to the canonical migration head:
For scripted management mutations, use qrtrustctl with --idempotency-key.
A retry with the same key and same mutation reuses the completed management
result; a retry with different mutation content returns 409 and does not
write another audit or outbox row. Bootstrap root and authority records before
enrolling issuers:
The CLI can also run from the optional management-tools Compose profile when
operator scripts need the same packaged runtime as the API container. Use
make qrtrustctl-container-help to inspect the containerized command surface.
Set QRTRUSTCTL_ADMIN_TOKEN in the shell or pass --admin-token; the container
defaults QRTRUSTCTL_BASE_URL to http://api:8000 and does not add another
database writer.
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-root-demo \
root-program-upsert \
--root-program-id root:qrtrust-demo:2026 \
--name "QR Trust Demo Root" \
--program-scope "demo merchant QR trust" \
--accepted-algorithm-id ES256
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-authority-demo \
delegated-authority-upsert \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--name "Merchant Web Authority" \
--authority-type merchant_operator
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-issuer-demo \
issuer-enroll \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--issuer-id issuer:acme-demo \
--display-name "ACME Demo" \
--issuer-class business \
--assurance-tier domain_controlled
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-domain-proof-demo \
domain-proof-upsert \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--issuer-id issuer:acme-demo \
--domain acme.example \
--proof-method manual_review \
--verification-status verified \
--evidence-ref operator://manual-review/acme.example
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-issuer-active-demo \
issuer-status-update \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--issuer-id issuer:acme-demo \
--enrollment-status active
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-policy-demo \
destination-policy-upsert \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--issuer-id issuer:acme-demo \
--destination-policy-id policy:acme-demo:web-payments:v1 \
--approved-destinations-json '[{"destination_id":"dest:acme-demo:pay","expected_final_url":"https://acme.example/pay","allowed_hosts":["acme.example"],"allow_subdomains":false,"path_prefixes":["/pay"],"query_policy":"allow_known_payment_query"}]'
Destination-policy upsert is intentionally guarded. It only writes the policy, audit row, and outbox event when the issuer is active and every approved host has a current verified domain proof in the same root, authority, and issuer namespace.
Use the same management transaction path to suspend, revoke, expire, or reactivate an existing destination policy. This is the preferred emergency rollback path; it writes source state, audit history, and an outbox event without direct SQL:
python backend/scripts/qrtrustctl.py \
--admin-token local-lab-admin \
--idempotency-key idem-policy-revoke-demo \
destination-policy-status-update \
--root-program-id root:qrtrust-demo:2026 \
--delegated-authority-id authority:qrtrust-demo:merchant-web \
--issuer-id issuer:acme-demo \
--destination-policy-id policy:acme-demo:web-payments:v1 \
--status revoked
The /operator route also includes a read-only management evidence panel for
the same outbox and audit read paths. Use it for classroom or developer review
when you need to explain what the API and CLI are reading from Postgres.
The same route renders the scanner trust store from GET /verifier/trust-store:
one row per issuer with its status and verified domains, and one row per key
with its state and validity window. Rotate or revoke the demo key from the
lab's generate step and the table updates on the next refresh.
To smoke-test only the runtime-safety observation report contract, run:
For the reference-network worker path, add local NATS JetStream without making it the source of truth. The local broker uses static PoC users; the Makefile and Compose defaults keep separate credentials for the outbox publisher, governance subscriber, and runtime subscriber.
For the full local HTTPS admin stack with NATS propagation, use
make up-https-admin-shared-infra-nats. That target starts the API, frontend,
broker, outbox publisher, governance subscriber, and runtime subscriber together.
Run qrtrustctl demo-bootstrap first when the governance and runtime subscribers
need their DB-backed allowlist rows.
make up-nats
make check-network-stack-ready
make up-network-outbox-worker
make up-network-governance-subscriber-worker
make up-network-runtime-subscriber-worker
make check-network-worker-drill
The shared-infra preflight refuses the existing publisher database as the QR
Trust database name, checks the separate Postgres database, checks Redis DB 5,
and reports whether the optional NATS monitor is already online. Host-side Redis
checks use EXTERNAL_REDIS_SETUP_HOST (default 127.0.0.1), while Compose
containers still use EXTERNAL_REDIS_HOSTNAME (default host.docker.internal).
Use make check-network-stack-ready as the non-destructive readiness gate for
the reference-network path. It checks shared Postgres, Redis DB 5, and live
NATS propagation without resetting the QR Trust schema. Use
make check-network-worker-drill only when you want the stronger local worker
proof; it runs the reset-guarded live worker smoke against the separate
qr_trust_poc database.
When overriding local NATS credentials, use the service-specific variables
QRTRUST_OUTBOX_NATS_USER, QRTRUST_GOVERNANCE_SUBSCRIBER_NATS_USER, and
QRTRUST_RUNTIME_SUBSCRIBER_NATS_USER with their matching password variables.
Do not collapse all workers onto one NATS user; the local broker permissions are
intentionally narrower per worker role.
Use make up-network-governance-subscriber-worker after authorizing the
subscriber through /admin/nats/subscribers or qrtrustctl demo-bootstrap.
The worker loads QRTRUST_GOVERNANCE_SUBSCRIBER_ID from Postgres, applies the
DB-managed subject allowlist, fetches authoritative artifacts by reference, and
materializes verifier-cache state. Tail it with
make logs-network-governance-subscriber-worker.
Use make up-network-runtime-subscriber-worker after authorizing a subscriber
for qrtrust.*.runtime.verdict.observed.v1 or a broader runtime family subject
that covers it. qrtrustctl demo-bootstrap creates the default
subscriber:runtime-observations authorization alongside the governance
subscriber. The worker loads QRTRUST_RUNTIME_SUBSCRIBER_ID from Postgres, fetches
runtime-safety-observation artifacts by reference, validates artifact hashes,
and persists normalized rows into qr_trust.runtime_observations. Tail it with
make logs-network-runtime-subscriber-worker.
For federation, stale-cache, or cross-root contradiction demos, start the optional second verifier node:
The second node runs the same backend image under the verifier-federation
Compose profile, publishes on port 8001, uses verifier id
verifier:reference-http-runtime-b, and keeps Redis state separate from the
primary verifier. It still reads QR Trust source state from Postgres; the
different verifier id lets cache entries and scanner decisions be compared
without adding a second source-of-truth database.
Before capturing native evidence, run:
This writes and validates the ignored local provider profile, confirms the HTTPS verifier is reachable, prints the primary local verifier candidate, detects a paired physical iPhone, and reports if Developer Mode is disabled.
Native app scope:
- scan QR codes with the native camera
- submit the scanned payload to
/scanner/decisions - show green, orange, or red user-facing decisions
- preserve the destination so the user can still choose whether to open it
- show the four-layer decision path for reviewers or curious users
- keep local scan history according to the selected retention setting
Deterministic cross-device rule:
- generate the demo QR from the browser lab on the laptop
- do not click the browser lab's
Check scanner decisionaction before the phone scan - scan that laptop QR from the iPhone app
For the primary native mismatch test:
- browser lab
Scenario = payload-mismatch - expected iPhone result = red
Destination changed - expected decision-path stage =
payload_revalidation
See IPHONE_TEST_PLAN.md for the full step-by-step drill.
Important Runtime Note¶
main.py attempts a Redis connection during startup when REDIS_STARTUP_ENABLED=true. If Redis is not running, startup logs an error and warning, but the narrowed PoC still works because the verifier reference flow falls back to the process-local in-memory verdict cache, scan budgets and rate limits in verifier.py; those then count per API replica instead of across the fleet. Set REDIS_STARTUP_ENABLED=false when you want deterministic no-Redis startup behavior.
Containerized Stack¶
Preferred local shortcuts from the repository root:
make up-admin is the standard local path when you want the workbench to issue verifier API keys with:
Production should leave VERIFIER_BOOTSTRAP_ADMIN_TOKENS_ENABLED=false and use
DB-backed management keys instead of config admin tokens.
From the repository root:
This starts:
postgresredisapifrontend
The published API port defaults to 127.0.0.1:8000 for local development.
The published frontend port defaults to 127.0.0.1:5173.
The published Postgres and Redis ports default to 127.0.0.1:5432 and
127.0.0.1:6379.
For phone or tablet testing on the same Wi-Fi, override the API and frontend host bind:
If you want the frontend workbench reachable on the LAN as well:
You can also override the published port:
When another local stack already owns the default host ports, prefer this side-by-side command:
make up-admin API_PUBLISH_PORT=8010 FRONTEND_PUBLISH_PORT=5175 POSTGRES_PUBLISH_PORT=5433 REDIS_PUBLISH_PORT=6380
The PoC still uses its own containerized postgres and redis services; these
overrides only move their published host ports away from existing projects.
After the stack is running, verify both the backend verifier contract and the React workbench routes:
For an HTTPS stack started with make up-https-admin or
make up-https-admin-shared-infra, use:
The smoke target checks:
/verifier/status- API key issue, QR demo-material generation, and two accepted verifications of the same envelope inside its validity window
- the React root route
- the lab comparison route at
/?scenario=payload-mismatch&autogenerate=1&compare=valid
For the React app itself, run the route-query navigation smoke while the dev server is active:
That browser check pushes query-only route changes inside the same page session
and verifies that /, /about, and /operator refresh their state. It is
the regression guard for stale guided-track, scenario, or operator-handoff
state when only the query string changes.
If you run the React dev server outside Docker on 5173, point its Vite proxy
at the published compose API port:
The Vite proxy derives its default backend target from API_PUBLISH_HOST and
API_PUBLISH_PORT. Use VITE_BACKEND_TARGET=http://127.0.0.1:8010 only when
you need to override that derived target explicitly.
To capture browser evidence for the primary engineering outcomes, keep the dev server running and execute:
The capture script stores:
docs/public/evidence/browser/accepted.pngdocs/public/evidence/browser/payload-mismatch.png
The compose flow runs Alembic migrations before starting the API. To stop the stack:
The current compose baseline was validated by:
docker compose configdocker compose up -d --build- live verifier request cycle executed inside the API container
If your host already uses port 8000, change API_PUBLISH_PORT.
If your host already uses 5173, change FRONTEND_PUBLISH_PORT.
If your host already uses 5432 or 6379, change POSTGRES_PUBLISH_PORT or
REDIS_PUBLISH_PORT.
Phone Testing¶
To open the React workbench from your phone:
- Start the stack with LAN publishing:
export VERIFIER_ADMIN_TOKENS='["local-lab-admin"]'
export VERIFIER_BOOTSTRAP_ADMIN_TOKENS_ENABLED=true
API_PUBLISH_HOST=0.0.0.0 FRONTEND_PUBLISH_HOST=0.0.0.0 docker compose up -d --build
- Find your Mac's LAN IP. Common commands on macOS are:
- On the phone, while connected to the same Wi-Fi, open:
-
Open the React verifier workbench directly.
-
If verifier auth is enabled, enter the admin token in the workbench, issue a key, and the client will auto-load that key into the API key field.
Practical testing note:
- if you want to test the phone camera scanning path, display the generated QR on a second device such as your laptop screen
- if you only want to test the phone browser, upload a QR image or use
Verify current QR without scanning - if you scan the generated QR with the native Camera app instead of the workbench, the phone will open the QR payload URL directly and bypass verifier checks
- on iPhone Safari over plain
http://<mac-lan-ip>:...,getUserMediamay be unavailable because the page is not in a secure context; use image upload, direct verify, or HTTPS if you need camera capture
Local HTTPS With mkcert¶
If you want true iPhone Safari camera capture, use local HTTPS instead of plain LAN HTTP.
Generate local certs from the repository root:
That creates ignored local files under local/https/:
verifier-lab.pemverifier-lab-key.pemmkcert-rootCA.pem
Start the stack with TLS enabled:
FRONTEND_PUBLISH_HOST=0.0.0.0 \
FRONTEND_TLS_ENABLED=true \
VERIFIER_ADMIN_TOKENS='["local-lab-admin"]' \
VERIFIER_BOOTSTRAP_ADMIN_TOKENS_ENABLED=true \
docker compose up -d --build
Then open:
Important iPhone trust step:
- Move
local/https/mkcert-rootCA.pemto the iPhone. - Install the profile/certificate.
- Enable trust in:
Settings > General > About > Certificate Trust Settings
Without trusting the mkcert root CA on the phone, Safari will not treat the React workbench as a secure context for camera capture.
Minimal API Flow¶
- Call
POST /verifier/demo-materials - Use either:
- the returned
verify_requestwithPOST /verifier/verify, or - the returned
qr_payloadwithPOST /verifier/verify-scanned - Re-send the same request: it is accepted again, because every presentation of one envelope inside its validity window is evaluated the same way
- Optional: call
POST /verifier/demo-materialsagain with{"rotate_key": true}. The new artifact verifies under the successor key and the firstqr_payloadstill verifies throughPOST /verifier/verify-scanned, because a retired key keeps vouching for what it signed while current - Optional: call it with
{"key_state": "revoked"}and re-scan the artifact it returns:allowed = false,stage = "key_status", and theresidual_vectorissuer_chainentry carries causekey-revoked.GET /verifier/trust-storeshows the same key asrevoked
Browser Clients¶
Primary React workbench:
http://127.0.0.1:5173/in local developmenthttp://<mac-lan-ip>:5173/for LAN testing
It provides:
- runtime posture loading from
GET /verifier/status - scenario-based demo QR generation
- camera scanning using the browser
BarcodeDetectorAPI when available - bundled verifier-side image decode fallback for browsers without
BarcodeDetector - fallback camera decoding that pauses/resumes on
429instead of failing hard - selectable camera sources for multi-camera devices
- QR image upload decoding
- QR PNG download, payload copy, and fullscreen QR display for cross-device testing
- direct submission to
POST /verifier/verify-scanned - verifier API key issue and list refresh when admin tokens are configured
- result history across verifier stages
- A/B comparison of two scenarios that differ by one trust layer (see above)
If API key auth is enabled on the server, enter the key in the workbench's
API key field. The value is stored only in local browser storage and attached
as X-API-Key on verifier POST requests.
If you run a separate frontend origin instead of the proxied workbench, set
CORS_ORIGINS explicitly. The default public-safe runtime is same-origin.
Compose-Backed Live HTTP Smoke¶
If you want a real HTTP verification cycle against the running server rather
than TestClient, set an admin token and run the smoke script:
export VERIFIER_ADMIN_TOKENS='["local-lab-admin"]'
export VERIFIER_BOOTSTRAP_ADMIN_TOKENS_ENABLED=true
docker compose up -d --build
cd backend
VERIFIER_SMOKE_ADMIN_TOKEN=local-lab-admin ./.venv/bin/python scripts/verifier_live_http_smoke.py
The smoke script calls the live /verifier/status,
/admin/verifier-clients/api-keys/issue, /verifier/demo-materials, and
/verifier/verify-scanned endpoints over HTTP.
Request Logging Discipline¶
The public verifier surface uses sanitized request logging:
- request logs contain request ID, method, path, status, and duration
- request and QR payload bodies are not logged
- noisy third-party request logs are reduced to keep verifier traces readable
Removed Experimental API¶
The older broad experimental surface has been removed from the codebase and is no longer mounted under any flag:
/certificates/*/qrcodes/*/organizations/*
Those routes performed unauthenticated write operations and did not match the
public PoC boundary. Requests to them now return 404. The supported HTTP
surface is /verifier/*, /scanner/*, and /admin/*.
Public Scope Boundary¶
This run guide does not cover:
- filing artifacts
- private form generation
- patent-source documents
- local-only materials under
private/