Credential Details Backfill
Reads stored credentials that predate descriptive-field capture and writes the library-facing name, issuer, subject, and validity columns, plus the spec version the matching data-model bridge was resolved with. The library-records migration derives coreCredentialType for native rows from the declared type string. The normal backfill fills descriptive fields; only an exceptional native row the migration left without a core type uses the separate signed-type resolution path.
This is an operator-run backfill. It ships in the image but never runs on its own, because it fetches every tenant's stored artefact. A wrong details write is not itself destructive to the stored credential, but the fetch is an external side effect, and the window during which existing rows sit at EXTRACTION_PENDING should stay short.
Credentials issued after descriptive-field capture already carry these columns. Until this job has run, pre-existing rows remain readable and verifiable, and only their stored summary is missing. A native row whose migration could not resolve a core credential type is also absent from every type filter on the library list until the exceptional core-kind path records one; a row whose types name no core kind stays without one and never matches a type value.
Before running it
The migrations that add the descriptive columns and move them onto the library record must already have been applied. The library-records migration derives coreCredentialType for native rows from their declared type string. Run this job immediately after the migrations deploy, so existing rows do not sit at EXTRACTION_PENDING indefinitely.
Take a database backup first. The job does not rewrite the stored credential, but it does update every matching row's details columns.
Run a dry run before the write pass, so you can review which rows would change and which would fail.
pnpm backfill:credential-details -- --dry-run
docker compose run --rm -e SKIP_MIGRATIONS=true -e SKIP_SEED=true ri node_modules/.bin/tsx scripts/backfill-credential-details.ts --dry-run
A dry run performs the same per-row fetch, decrypt, and decode work as a live pass. It writes nothing. The one-off docker compose run form is recognised as a maintenance command, so the entrypoint does not run the boot preflight; the backfill's own checks still apply.
Running it
From a source checkout, in packages/reference-implementation:
pnpm backfill:credential-details
The published Docker image carries no package manifest for the Reference Implementation, so inside the image run the script directly:
docker compose run --rm -e SKIP_MIGRATIONS=true -e SKIP_SEED=true ri node_modules/.bin/tsx scripts/backfill-credential-details.ts
It needs a database target. A pre-set RI_DATABASE_URL is honoured as given, and the RI_POSTGRES_* variables are used to construct one only when it is absent, which is the same rule the application follows.
If a row holds an encrypted artefact, DATA_ENCRYPTION_KEY must match the key the application uses to unwrap the stored decryption key.
What it reports
The job selects every native record whose descriptive fields are still EXTRACTION_PENDING, and it can also select an exceptional row whose migration left coreCredentialType null. The migration derives coreCredentialType for native rows from the declared type string; the normal backfill fills the descriptive fields and does not recalculate that migrated type. The exceptional path resolves only a still-null core type from the signed credential's type array. A completed run reports how many rows it scanned, how many it updated (or would update, in a dry run), and how many failed (or would fail). Every failed row is listed by record id, error class, and message.
Three error classes name a problem reading the credential:
- UNREADABLE_ENVELOPE. The storage URI could not be fetched, or the body was not valid JSON, or the artefact was not a decodable enveloped credential. Check that the storage service is reachable and that the object at that URI is intact.
- DECRYPT_FAILED. The stored decryption key could not unwrap the artefact, or the at-rest key envelope could not be opened. Confirm
DATA_ENCRYPTION_KEYmatches the running application, and that the row's key is the one that stored the artefact. - BRIDGE_ERROR. No unique registered bridge version matched the credential's
@context, or a storedcoreDataModelVersionhas no bridge, or the extractor threw, or the credential's type array names no core credential type, or names two of them. Do not guess a version.
Once the cause is fixed, a record whose descriptive fields failed to extract is retried by setting its detailsStatus back to EXTRACTION_PENDING and re-running. See Re-running.
Two failure messages, reported under the class WRITE_FAILED, name no problem with the credential at all. One says the row's outcome could not be written, and the other says the record changed after it was read. Both mean this pass did not land that row, and a re-run picks it up.
A run that finishes its work with no failures exits 0. It exits 1 when any row failed, so an automated caller notices.
One failed row does not abort the batch. Every other row is still attempted. A record selected because its descriptive fields were pending is marked EXTRACTION_FAILED, so a later run leaves it alone until an operator resets it. A record selected only for an unresolved core credential type keeps whatever status it had, because the run was never reading its details.
Re-running
Re-running is safe. A record whose descriptive fields were extracted is not selected again, and neither is one whose unresolved core credential type has been filled in, so a second run over a table with nothing left to resolve reports zero rows changed.
A record whose core credential type is still unknown after a run is the exception, and that is by design. It is selected again on every run, reported again, and the job exits 1 for as long as one exists, so a scheduled run keeps failing rather than leaving the record unresolved and unmentioned. Where the cause was an artefact that could not be fetched or opened, fixing that and re-running resolves it. Where the credential's type array names no core credential type, or names two of them, nothing in the artefact says which core type the record belongs to, so no re-run will resolve it and an operator has to.
Resetting such a record's detailsStatus to EXTRACTION_PENDING does not resolve its core credential type, and on a record whose details were extracted it costs you what you have. The next run treats the record as a full extraction, fails on the same missing type, and marks the record EXTRACTION_FAILED, so a field the credential genuinely does not carry stops reading as absent and starts reading as unknown. Set the record's coreCredentialType by hand instead, to the core type the credential belongs to, or remove the record. Where the record's descriptive fields never extracted either, set the core type first and reset detailsStatus afterwards, so the next run has a bridge to read the credential with.
A record whose descriptive-field extraction failed stays failed until you reset it to EXTRACTION_PENDING. That is deliberate: a retry must be an operator decision after the cause is understood, not an automatic re-derivation.