โ† all docs

mDL / Photo ID issuance โ€” manual verification checklist

Purpose: several steps in the mdoc issuance path can only be checked by
actually issuing a credential and decoding it. This file records what was
verified, what was not, and exactly what has to be redone on the next
walt.id upgrade.

Date performed 2026-08-21
walt.id version waltid/issuer-api:0.23.1, waltid/issuer-api2:0.23.1
Branch feat/mdl
Verifier used internal/mdl/testdata/verify โ€” @owf/mdoc 0.7.0 + @owf/cose, on node:crypto
Performed by automated agent session (no browser, no phone โ€” see Steps 2/6/7)

Read the Findings section before trusting any green mark below. Three real
defects were found. F1 and F2 are fixed (F2 on 2026-09-01 โ€” see its
own section below for what changed); F3 remains open and is on the debt
list.

This document is a verification log, not an incident runbook. If you are
here because something is actually broken in a running deployment (DSC
expired, issuer2-profiles.conf corrupted, unsure what a rotation does to
already-claimed wallets), go to
mdl-incident-runbook.md instead.


Findings (read first)

F1 โ€” FIXED: issuerKey.jwk was substituted as a string, not an object

Was deploy/k8s/config/issuer2/issuer2-profiles.conf:

defaultIssuerKey = {
  type = "jwk"
  jwk = ${VERIFIABLY_ISSUER2_KEY}      # <-- substitutes as a STRING
}

HOCON substitutes an environment variable as a string and never re-parses
it as JSON. walt.id needs a nested object here โ€” the shipped original was a
nested HOCON object. GET /issuer2/profiles showed the damage:

"issuerKey": { "type": "jwk", "jwk": "{\"kty\":\"EC\",\"crv\":\"P-256\",...}" }

Note jwk is a quoted, escaped string. Consequence: every mdoc issuance
failed
at the credential endpoint with

HTTP 500 {"error":"server_error","error_description":"Invalid JSON object"}

thrown from com.nimbusds.jose.util.JSONObjectUtils.parseJSONObject.

The dangerous part: this was invisible to a boot check. The service
started cleanly, /issuer2/profiles returned 200 with the key present, and
the wellknown was correct. It only appeared when a wallet actually asked for
a credential. Task 2's verification stopped at exactly those two signals.
Booting a service is not exercising it.

The fix. The JWK is now supplied field by field, so each substitution is
a plain string in a position that wants a string, and jwk itself is a real
HOCON object:

defaultIssuerKey = {
  type = "jwk"
  jwk = {
    kty = "EC"
    crv = "P-256"
    x = ${VERIFIABLY_ISSUER2_KEY_X}
    y = ${VERIFIABLY_ISSUER2_KEY_Y}
    d = ${VERIFIABLY_ISSUER2_KEY_D}
  }
}

VERIFIABLY_ISSUER2_KEY is replaced by VERIFIABLY_ISSUER2_KEY_X / _Y /
_D in docker-compose.yml and .env.example. Bare ${VAR} (not ${?VAR})
is kept, so a missing key still stops the boot โ€” verified: removing
VERIFIABLY_ISSUER2_KEY_D makes docker compose config exit 1 with
required variable VERIFIABLY_ISSUER2_KEY_D is missing a value.

Verified by issuing, not by booting. With the fixed config,
/issuer2/profiles reports issuerKey.jwk as an object with
['crv','d','kty','x','y'], and a full OID4VCI pre-authorized-code exchange
against the unmodified repo config produced a real 7191-byte mDL โ€” the
same request that previously returned 500 "Invalid JSON object". Decoded:

PASS  IssuerSigned decodes under @owf/mdoc schemas
PASS  docType is org.iso.18013.5.1.mDL
PASS  birth_date is CBOR tag 1004 (full-date), not a text string
PASS  portrait is a CBOR byte string โ€” 70 bytes
CBOR tag 1004 (full-date) occurrences: 8, each followed by 0x6a

Do not revert this to the single-variable blob form. The reasoning is
recorded in the config comment above defaultIssuerKey.

Note the contrast, now corrected in issuer-service.conf's comment:
ciTokenKey and credentialEncryptionKey genuinely DO take a JSON string
(walt.id ships them as triple-quoted strings), so the single-variable
substitution is correct for those two and must not be "harmonised" with the
issuer key. The old comment claimed HOCON "parses the substituted JSON text
as an object" โ€” that is false, and it is precisely the belief that produced
this bug one file over. The code was right for the wrong reason.

F2 โ€” Photo ID emits portrait as a text string, not a byte string

The mDL profile maps the portrait to CBOR bytes:

"portrait" = { "type" = "string", "conversionType" = "base64StringToByteString" }

The Photo ID profile has no portrait entry in its
mDocNameSpacesDataMappingConfig
โ€” only birth_date, issue_date,
expiry_date, portrait_capture_date. So the portrait is emitted as a
base64 text string. Confirmed by decoding a real issued Photo ID:

FAIL  portrait is a CBOR byte string โ€” js type string

This is walt.id's own shipped defect, verified by reading the profile
inside the unmodified waltid/issuer-api2:0.23.1 image โ€” not a regression
introduced by Task 2's trimming.

Adding the same four-line mapping the mDL profile already has fixes it
completely (tested in a scratch copy โ€” portrait is a CBOR byte string โ€” 70 bytes, all 8 checks pass). Not applied to the repo: out of scope here.

Update (2026-09-01): F2 is fixed. The four-line mapping described
above is now in issuer2-profiles.baseline.conf's isoPhotoId block โ€”
confirmed the deferral reason ("out of scope", pending "its own review")
resolved to nothing blocking: credentialData already carried the
portrait key as a null placeholder, so this was purely the missing
entriesConfigMap entry, not a data-shape decision. Every "F2" mark
further down this document (Step 6's table, the migration checklist)
describes the PRE-FIX state as observed on 2026-08-21 โ€” kept as the
historical record, not something to re-verify against a current
deployment.

F3 โ€” verify.mjs does not take a file argument

The brief's Step 4 says node verify.mjs <path-to-issued-mdoc>. It does not
accept one โ€” verify.mjs is hardcoded to ../vectors/mdl_full.cbor,
iaca.pem, dsc.pem, and asserts exactly 13 elements and the
POC-DO-NOT-TRUST subject markers, all specific to the Go-generated vectors.
It verifies internal/mdl's own output, which is its documented job.

For this checklist the same @owf/mdoc + node:crypto MdocContext was
driven against the walt.id-issued credentials instead. Same library, same
primitives, same checks โ€” different input. If checking issued credentials is
meant to be routine, verify.mjs should be given an optional path argument.


Steps

Step 1 โ€” Bring the stack up โ€” DONE (reduced scope)

The brief says docker compose up -d for the whole stack. That is 53
services
, and compose ${VAR:?} guards hard-block up with no .env
present: VERIFIABLY_ISSUER2_KEY_X / _Y / _D (three separate guards
after the F1 fix), VERIFIABLY_ISSUER2_CI_TOKEN_KEY, and
VERIFIABLY_ISSUER2_CRED_ENCRYPTION_KEY.

Three disposable EC P-256 JWKs were generated into a scratch env file
(never committed) and only the services issuance needs were started:
issuer-api, issuer-api2, caddy.

Update (2026-08-31): the scratch-file step above is no longer
needed.
provision_issuer2_certificates (scripts/gen-caddy.sh) now
generates VERIFIABLY_ISSUER2_CI_TOKEN_KEY and
VERIFIABLY_ISSUER2_CRED_ENCRYPTION_KEY automatically on first deploy โ€”
same mechanism VERIFIABLY_ISSUER2_KEY_X/_Y/_D already used โ€” and
persists them into .env via scripts/common.sh's set_env_var (fixed
the same day to quote JSON values correctly, closing the exact
single-quoting trap docs/deploy.md's ".env quoting for the issuer2
JSON key variables" section warns about). A from-scratch ./deploy.sh up
needs no manual key generation for any of the five issuer2 JWK
variables. See cmd/mdl-pki-gen/main_test.go's
TestAuxKeys* tests for the guarantees this now carries.

Both issuers reached Web server ready! โ€” issuer-api2 in 3773 ms.
verifiably-go was not started; it is not on the mdoc issuance path
exercised here (see Step 2).

Step 2 โ€” Issue an mDL through the real operator flow โ€” NOT PERFORMED

The browser UI path was not exercised. No operator, no browser session.
This step exists specifically to prove the path the operator actually takes,
and driving the HTTP API is not that. It must be redone by a human.

What was done instead: credentials were issued by driving issuer-api2's
OID4VCI endpoints directly, to produce real signed artifacts worth verifying
in Steps 4โ€“6. That covers the issuance path but says nothing about the
schema-picker UI, field rendering, or the operator's form.

Offer URI form observed (from the API):

openid-credential-offer://?credential_offer_uri=http%3A%2F%2Flocalhost%3A7002%2Fopenid4vci%2Fcredential-offer%3Fid%3D<uuid>

Step 3 โ€” Issuer display name in the wellknown โ€” PASS

The brief's URL is wrong. The real path carries the /draft13 prefix:

curl -s "http://localhost:7002/draft13/.well-known/openid-credential-issuer"
"display": [
  { "name": "INTRANT", "locale": "en",
    "description": "Instituto Nacional de Transito y Transporte Terrestre" }
]

The configured name, not the URL. The brief's bare
/.well-known/openid-credential-issuer returns 404 โ€” confirmed.

issuer-api2 advertises its own metadata at yet another path,
/.well-known/openid-credential-issuer/openid4vci (RFC 8414 suffix form),
also carrying "name": "INTRANT".

Step 4 โ€” Retrieve the credential and verify CBOR types independently โ€” PASS

This is the payoff step: @owf/mdoc (OpenWallet Foundation, also used by
Credo) checking walt.id's output, on node:crypto primitives, with nothing
in the trust path from our Go code.

Full OID4VCI pre-authorized-code exchange completed: offer โ†’ resolve โ†’
token โ†’ nonce โ†’ holder-bound ES256 proof JWT โ†’ credential. Issued mDL:
7218 bytes.

Note the credential endpoint takes an encrypted JWE request
(Content-Type: application/jwt, ECDH-ES + A128GCM to the issuer's published
credential_request_encryption key). Plain JSON works too, but any request
carrying credential_response_encryption is rejected unless the request
itself is encrypted.

PASS  IssuerSigned decodes under @owf/mdoc schemas
PASS  docType is org.iso.18013.5.1.mDL
PASS  chain terminates in the trust anchor โ€” 2 certificates
PASS  disclosed elements had digests recomputed โ€” 46 digest comparisons
PASS  birth_date is CBOR tag 1004 (full-date), not a text string
PASS  portrait is a CBOR byte string โ€” 70 bytes
      full issuer verification โ€” 56 assessments, 1 failed (see below)

Byte-level confirmation of the tag, independent of any library:

CBOR tag 1004 (full-date) occurrences: 8, each followed by 0x6a

0xD9 0x03 0xEC is tag 1004; 0x6A is a 10-character text string โ€”
YYYY-MM-DD. Exactly the ISO 18013-5 full-date encoding.

Signature and chain validate. The 1 failed assessment of 56 is
issuing_jurisdiction ("") not matching stateOrProvinceName (absent) in
the signing certificate โ€” an artifact of the diagnostic certificate used here
(see the x5chain caveat below) combined with the profile's emptied sample
field, not a defect in the CBOR encoding.

x5chain caveat โ€” the signature check above is only meaningful because the
certificate was replaced.
As shipped, issuer2-profiles.conf pairs the
operator's key (VERIFIABLY_ISSUER2_KEY_X/_Y/_D) with walt.id's
published example certificate, whose embedded public key is a different
keypair. Task 2
flagged this as a hard gate. Every mdoc issued in that configuration is
COSE-signed with one key while advertising another in its x5chain, and fails
signature verification at any conformant reader. To make Step 4 a real test,
a throwaway IACA + DSC covering the actual signing key was generated for this
session only. That is a diagnostic aid, not a fix โ€” real certificate
provisioning is owned by
docs/superpowers/specs/2026-07-23-issuer-key-rotation-design.md.

Step 5 โ€” Omission safety property, on a real issuance โ€” PASS

An mDL was issued supplying only family_name and given_name (plus the
date fields the profile's stringToFullDate mapping forces โ€” see below).
The decoded credential was searched for walt.id's Austrian sample data:

string occurrences
Musterfrau 0
Muster 0
Bundesministerium 0
Vienna 0
walt.id 0
AT 0

Every field not supplied came through as the emptied placeholder โ€” "",
0, false, null โ€” never sample data:

family_name = "Perez"      given_name = "Ana Maria"
document_number = ""       issuing_authority = ""     issuing_country = ""
birth_place = ""           nationality = ""           resident_address = ""
sex = 0                    height = 0                 age_over_18 = false

Task 2's emptying of the profile sample data holds end to end. This
pins the whole path, not just the request side that the Task 3 unit test
covers.

One wrinkle worth knowing: a truly minimal request fails with
DateTimeParseException: Text '' could not be parsed at index 0, because
the emptied "" date fields still run through stringToFullDate. Real
dates must be supplied for issuance to succeed at all. Also,
driving_privileges must carry exactly 2 entries or issuance fails with
Json array sizes (input & config) are not equal โ€” the mapping config
declares a fixed 2-element array.

Update (2026-08-31): the "exactly 2" constraint above is no longer
true.
The 2026-08-24 driving-privileges-variable-count work replaced
the single fixed-2-element isoMdl profile with four profiles โ€”
isoMdl_1cat through isoMdl_4cat, deploy/k8s/config/issuer2/issuer2-profiles.baseline.conf
โ€” selected server-side by the REAL category count
(internal/adapters/waltid/issuer2.go's mdlProfileForCategoryCount).
driving_privileges now accepts 1 to mdoc.DrivingPrivilegesMaxCategories
(4) entries, exact match to whichever profile that count selects โ€” no
padding, and 0 categories is an explicit rejection (mandatory ISO/IEC
18013-5 Table 3 element), not a silent default. This paragraph is kept
as a historical record of what Step 5 actually observed at the time; do
not follow its "exactly 2" claim for a current deployment.

Step 6 โ€” Repeat for Photo ID โ€” PASS with a real defect (F2)

Issued through the same API path, docType org.iso.23220.photoid.1
(lowercase photoid โ€” capital photoID does not resolve), namespace
org.iso.23220.1. Issued Photo ID: 20929 bytes, 125 elements.

PASS  IssuerSigned decodes under @owf/mdoc schemas
PASS  docType is org.iso.23220.photoid.1
PASS  full issuer verification โ€” 164 assessments, 0 failed
PASS  chain terminates in the trust anchor โ€” 2 certificates
PASS  disclosed elements had digests recomputed โ€” 154 digest comparisons
PASS  birth_date is CBOR tag 1004 (full-date)
FAIL  portrait is a CBOR byte string โ€” js type string      <-- F2

164 assessments, 0 failed โ€” a completely clean cryptographic
verification, better than the mDL's (the issuing_jurisdiction element that
tripped the mDL does not exist in this namespace).

The per-docType design is confirmed genuine: Photo ID's mandatory set really
is different โ€” 9 fields, issuing_authority_unicode rather than
issuing_authority, and age_over_18 mandatory rather than optional. The
namespace is org.iso.23220.1, which is not what stripping the docType's
last segment would give (org.iso.23220.photoid) โ€” the explicit
docTypeProfiles table in internal/adapters/waltid/issuer2.go is doing
real work.

Only portrait is wrong, and it is walt.id's shipped mapping gap (F2).

Also unreachable through the Go adapter as written: buildIssuer2Offer
types credentialData as map[string]string, but driving_privileges
requires an array of objects and age_over_18 a boolean. Any mDL issued
through the Go path today cannot supply driving_privileges โ€” one of the 11
ISO mandatory elements.

Step 7 โ€” Verify with the real reader โ€” NOT PERFORMED

No Android device was attached (adb devices empty), so
multipaz-identity-reader could not be run. Nothing was presented to a real
reader; the portrait was never rendered on a device.

This is the only step that exercises a genuinely independent, conformant ISO
18013-5 verifier end to end, including the IACA-import trust flow. It
must be performed by a human before any release claim. Steps 4โ€“6 verify
the same cryptography in software, which is strong evidence but is not the
same as a real reader accepting the credential.

Step 8 โ€” This document โ€” DONE

Step 9 โ€” Commit โ€” DONE


Summary

Step Result
1 Stack up DONE โ€” 3 of 53 services, keys generated
2 Issue via operator UI NOT PERFORMED โ€” no browser; API used instead
3 Wellknown display name PASS โ€” /draft13 prefix required
4 mDL CBOR types verified independently PASS โ€” tag 1004, portrait bytes, signature + chain valid
5 Omission safety PASS โ€” zero Austrian sample data leaked
6 Photo ID PASS except portrait (F2, upstream defect)
7 Real reader NOT PERFORMED โ€” no Android device
8 This document DONE
F1 fix re-verified PASS โ€” 7191-byte mDL issued from the fixed repo config

F1 โ€” the issuance-blocking issuerKey.jwk string/object defect โ€” is FIXED
in this branch and re-verified by issuing a real credential, not by booting.

F2 โ€” fixed 2026-09-01 (was open at the time this checklist was written:
walt.id's Photo ID profile omitted the portrait byte-string mapping, so
every Photo ID portrait was emitted as text). See the update note on F2's
own section above. F3 (verify.mjs takes no path argument) is still open
and on the debt list. F4 (the Go adapter's map[string]string cannot
express driving_privileges) was fixed separately, well before this
update โ€” see TODO.md.


One-time migration โ€” existing deployments, baseline/runtime config split

Applies to: any deployment created before issuer-api2's two configs were
split into tracked *.baseline.conf seeds plus gitignored runtime files.
A fresh clone needs none of this โ€” deploy.sh seeds both files itself.

Why it is needed

deploy/k8s/config/issuer2/issuer2-profiles.conf and
credential-issuer-metadata.conf used to be tracked in git while holding
generated and operator-authored content: the deployment's real DSC/IACA
x5chain (rendered in by provision_issuer2_certificates) and the operator's
custom mdoc schema display name (written by setIssuer2Display). Every
git pull / checkout / stash pop reverted both. The tracked files are now
*.baseline.conf seeds and the runtime files are gitignored.

What happens if you just pull

The pull refuses and aborts โ€” it does not destroy anything:

error: Your local changes to the following files would be overwritten by merge:
	verifiably-go/deploy/k8s/config/issuer2/credential-issuer-metadata.conf
	verifiably-go/deploy/k8s/config/issuer2/issuer2-profiles.conf
Aborting

Git blocks it because the rename deletes files you have locally modified. Your
certificates and schema name are still on disk at this point. Do not run
git checkout -- or git stash on these two files to "clear" the block โ€”
that is precisely what discards the DSC inside every mdoc already issued.

The migration (run from the repo root, on the deployment host)

cd deploy/k8s/config/issuer2

# 1. Copy the two runtime files somewhere outside the repo.
cp issuer2-profiles.conf           ~/issuer2-profiles.conf.save
cp credential-issuer-metadata.conf ~/credential-issuer-metadata.conf.save

# 2. Return the tracked copies to their committed state so the pull is clean.
cd -
git checkout -- deploy/k8s/config/issuer2/

# 3. Pull. The rename now applies without conflict.
git pull

# 4. Put the real files back under the (now gitignored) runtime names.
cp ~/issuer2-profiles.conf.save \
   deploy/k8s/config/issuer2/issuer2-profiles.conf
cp ~/credential-issuer-metadata.conf.save \
   deploy/k8s/config/issuer2/credential-issuer-metadata.conf

Verify before redeploying

# Both runtime files must now be ignored (each prints a .gitignore line):
git check-ignore -v \
  deploy/k8s/config/issuer2/issuer2-profiles.conf \
  deploy/k8s/config/issuer2/credential-issuer-metadata.conf

# Your real certificate must still be in place โ€” this must NOT match:
grep -c MIIBeTCCAR8CFHrWgrGl5KdefSvRQhR \
  deploy/k8s/config/issuer2/issuer2-profiles.conf   # expect 0

git status --short   # expect clean

MIIBeTCCAR8... is walt.id's published example certificate. If that grep
returns non-zero, the real x5chain was lost โ€” restore it from
~/issuer2-profiles.conf.save before deploying, because credentials issued
with the example certificate are refused by every wallet and nothing in the
logs says so.

The next deploy.sh is then a no-op for both files: seed_issuer2_configs
uses cp -n, so it only creates a runtime file that does not already exist.
Keep the ~/*.save copies until you have issued and verified one credential.


Dynamic wallet trust anchors (2026-08-23) โ€” closes the recompile-per-deploy gap

Applies to: any deployment created before GET /trust/mdoc-anchors
existed. A fresh clone/deploy plus a wallet build from current main needs
none of this โ€” it works out of the box.

Before this, a wallet only trusted a certificate compiled into the app
(src/agent/setup.ts::MDOC_TRUSTED_CERTIFICATES in cdpi-wallet). Since
provision_issuer2_certificates generates a brand-new IACA whenever
dsc.pem doesn't already exist, every from-scratch redeploy silently broke
every wallet build until it was recompiled with the new certificate โ€”
"No trusted certificate was found" at accept time, with nothing in the
server logs pointing at the cause (the offer and credential endpoints both
return 200; only the wallet's local signature check fails).

Fixed by verifiably-go publishing its current IACA over HTTP
(GET /trust/mdoc-anchors, internal/handlers/mdoc_anchors.go) and the
wallet fetching it dynamically per credential_issuer
(src/agent/mdocTrustAnchors.ts), unioned with the compiled-in fallback so
already-issued credentials keep verifying. Full design rationale and the
documented VICAL production path:
docs/superpowers/adr/2026-08-23-mdl-trust-anchor-distribution.md.

The routing gap this surfaced, and how to recognize it again

The endpoint lives on the verifiably-go container. The wallet fetches it
from the walt-issuer2 origin โ€” the credential_issuer it resolved
the offer from, a different container/subdomain. The first deploy of this
feature exposed the endpoint on verifiably-go's own domain only;
walt-issuer2's Caddy block still allowlisted just /openid4vci/* and
/.well-known/* (deliberately narrow โ€” issuer-api2 has no auth on its
management API), so the wallet's fetch 404'd there while curl against
verifiably-go directly returned 200 the whole time.

Symptom: wallet reports "No trusted certificate was found" even though
the dynamic-anchor mechanism is confirmed built, tested, and deployed.

Diagnosis (reproduce before assuming it's this):

# 1. What origin does the wallet actually fetch from?
curl -s "https://<walt-issuer2 subdomain>/.well-known/openid-credential-issuer/openid4vci" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['credential_issuer'])"
# => https://<walt-issuer2 subdomain>/openid4vci

# 2. Does the anchor endpoint answer AT THAT EXACT ORIGIN?
curl -s -o /dev/null -w "%{http_code}\n" "https://<walt-issuer2 subdomain>/trust/mdoc-anchors"
# 200 = fine, look elsewhere. 404 = this routing gap.

# 3. Sanity: same path on verifiably-go's own domain, for comparison.
curl -s -o /dev/null -w "%{http_code}\n" "https://<verifiably subdomain>/trust/mdoc-anchors"

Fixed in scripts/gen-caddy.sh (commit 2afe956): the walt-issuer2
Caddy block now has a third allowlisted rule,
handle /trust/mdoc-anchors { reverse_proxy verifiably-go:8080 }, ahead of
the catch-all 404 โ€” the only new path opened, allowlist otherwise
unchanged. If you see this symptom on a deployment whose gen-caddy.sh
predates that commit, git pull and re-run ./deploy.sh up waltid
(regenerates and Caddy needs a reload โ€” docker exec <public caddy container> caddy reload --config /etc/caddy/Caddyfile if the container
name differs from what deploy.sh already reloads).

Verified working end to end (2026-08-23)

Confirmed live against mtc.credenciales.ysalabs.work, not just unit
tests: a from-scratch-equivalent redeploy regenerated the IACA
(CN=VERIFIABLY POC IACA, freshly issued that day) โ€” different from the
CN=INTRANT POC IACA compiled into the wallet's static fallback. After
the Caddy fix:

  • walt-issuer2 origin's /trust/mdoc-anchors โ†’ 200, serving the new cert.
  • verifiably-go's own domain โ†’ still 200 (unaffected by the fix).
  • walt-issuer2 origin's /issuer2/sessions (unauthenticated management
    API) โ†’ still 404 (allowlist not weakened).

Still needs a human: an on-device accept of a credential signed by
this rotated IACA, with the wallet build unchanged (no recompile), to
confirm the dynamic mechanism closes the loop for a real reader โ€” the curl
checks above prove the plumbing, not the on-device Credo verification
path.

What must be repeated on the next walt.id upgrade

All of it โ€” every step here is manual. In particular:

  1. Steps 2 and 6 through the browser UI, by a human operator. Never
    satisfied by driving the API.
  2. Step 7 against multipaz-identity-reader on a real Android device,
    including the IACA import. Never satisfied in software.
  3. Steps 4 and 5 re-run against freshly issued credentials. walt.id
    changing a conversionType default, or re-adding sample data to a
    shipped profile, would be invisible to every unit test in this repo.
  4. Re-check F1 and F2. Both are fixed in this repo's own profile files โ€”
    confirm an upgrade does not silently revert either. For F1, confirm the
    upgrade did not reintroduce the issuerKey.jwk blob form, and remember
    that only an issuance detects it, never a boot. For F2, if upstream's
    NEW default isoPhotoId profile now ships its own portrait mapping,
    diff it against this repo's local fix before merging โ€” a straight
    overwrite of issuer2-profiles.baseline.conf from a fresh upstream
    image would silently drop the local fix if upstream's shape differs
    even slightly (field order, conversionType casing) from what
    profiletrim_test.go's expectedPhotoIdMappings expects.
  5. Re-check the wellknown paths. /draft13/... and
    /.well-known/openid-credential-issuer/openid4vci are walt.id's
    choices and have already moved once.
  6. Re-check the credential request shape โ€” the encrypted-JWE
    requirement, the driving_privileges array (variable 1โ€“4 entries as of
    2026-08-24, see the update note on Step 5 above โ€” no longer fixed at 2),
    and the stringToFullDate behaviour on empty strings are all
    undocumented walt.id behaviours found by trial here.

Inji Certify Pre-Auth โ€” mDL / Photo ID (second emitter, added 2026-08-25)

This checklist predates Inji Certify's mso_mdoc integration by four days and
was never extended to cover it โ€” everything above is walt.id/issuer-api2
only. The equivalent operational detail for the Inji Certify path (how an
operator reaches it, the namespace-resolution mechanism, the
driving_privileges guard, and the one still-open upstream limitation โ€”
portrait not bstr-encoded) now lives in
dpg/inji-certify-preauth.md's "mso_mdoc
(mDL / Photo ID) issuance" section, kept there rather than duplicated here
so the two don't drift out of sync the way this checklist's own
driving_privileges note above did. A verification pass through the F1โ€“F7
steps above, repeated against the Inji Certify path specifically (a human
issuing through the browser UI with a Pre-Auth Inji DPG selected, and
independent verification of the resulting mdoc), has not yet been logged
anywhere โ€” that remains open work, not something this update closes.

Source: docs/mdl-issuance-manual-checklist.md