Skip to content

Tier 2 schema mapper is not UNTP 0.7.0-aware: every 0.7.0 credential fails with "expected null to be a string" #711

Description

@halleluiaman-ux

Package: @uncefact/untp-test-suite v0.1.0 (monorepo uncefact/tests-untp @ d09f538, tag reference-implementation-v0.3.0)
Tier: 2 (UNTP schema validation)
Severity: Tier 2 is unusable for any UNTP v0.7.0 credential.

Summary

In UNTP v0.7.0 the per-type JSON-LD context URLs were replaced by a single unified
context, https://vocabulary.uncefact.org/untp/0.7.0/context/ (see the RI v0.7.0
migration guide). The Tier 2 schema mapper still derives the schema URL from the
0.6.x context scheme (https://test.uncefact.org/vocabulary/untp/<abbr>/<version>/),
so it cannot extract a version from a 0.7.0 credential. getSchemaUrlForCredential
returns null and the test fails at the guard assertion, never reaching the actual
schema validation.

This is independent of schema availability: the official v0.7.0 schemas are
published (under https://untp.unece.org/artefacts/schema/v0.7.0/...), just not at
the path the mapper derives.

Reproduction (minimal)

Any DPP whose @context uses the unified 0.7.0 context:

{
  "@context": [
    "https://www.w3.org/ns/credentials/v2",
    "https://vocabulary.uncefact.org/untp/0.7.0/context/"
  ],
  "type": ["DigitalProductPassport", "VerifiableCredential"],
  "issuer": { "type": ["CredentialIssuer"], "id": "did:web:example.org" },
  "validFrom": "2026-01-01T00:00:00Z",
  "credentialSubject": { "type": ["Product"], "name": "Example" }
}
npx untp-test --tag tier2 ./dpp-0.7.0.json

Observed:

Tier 2 - UNTP Schema Validation  (tags: tier2, untp)
  dpp-0.7.0.json
    ✖ should validate against DigitalProductPassport UNTP schema  (tags: schema)
      Should be able to determine UNTP schema URL for credential: expected null to be a string

Proof the credential is actually valid against the official 0.7.0 schema.
Using the suite's own validation path (createAjvInstance + validateJsonAgainstSchema)
pointed at the official schema, the same credential passes:

const { createAjvInstance } = require('@uncefact/untp-test-suite/dist/untp-test/utils.js');
const { validateJsonAgainstSchema } = require('@uncefact/untp-test-suite/dist/untp-test/test-utils.js');
const cred = require('./dpp-0.7.0.json');
const ajv = createAjvInstance();
const res = await validateJsonAgainstSchema(
  cred,
  'https://untp.unece.org/artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json',
  ajv,
);
console.log(res.valid); // -> true

Root cause

Two places hardcode the 0.6.x context/URL scheme:

  1. src/untp-test/schema-mapper/default-mappings.json — every versionRegex matches
    only https://test\.uncefact\.org/vocabulary/untp/<abbr>/([^/]+)/, and every
    schemaUrlPattern points at test.uncefact.org/.../untp-<abbr>-schema-{version}.json.
  2. src/untp-test/test-utils.ts::extractUNTPVersion (lines ~294–305) builds the same
    test.uncefact.org/vocabulary/untp/(<abbr>)/([^/]+)/ regex.

There is also a selection bug that blocks the obvious workaround (adding a 0.7.0
mapping entry): src/untp-test/schema-mapper/loader.ts::getSchemaUrlFromMappings
selects the mapping with mappings.find(m => m.credentialType === targetType) — the
first entry for that type — and if its versionRegex doesn't match, returns null
instead of trying the next matching entry. So a second (0.7.0) mapping for the same
credentialType is never reached.

Suggested fix

(a) Make mapping selection fall through on non-match so multiple version schemes
can coexist (loader.ts):

- // Find matching mapping by credential type
- let matchedMapping: SchemaMappingConfig | undefined;
- if (credential.type.includes(targetType)) {
-   matchedMapping = mappings.find((mapping) => mapping.credentialType === targetType);
- } else {
-   return null;
- }
- if (!matchedMapping) {
-   return null;
- }
- if (!matchedMapping.versionRegex) {
-   return matchedMapping.schemaUrlPattern;
- }
- const credentialString = JSON.stringify(credential);
- const versionMatch = credentialString.match(new RegExp(matchedMapping.versionRegex));
- if (!versionMatch || !versionMatch[1]) {
-   return null;
- }
- const version = versionMatch[1];
- return matchedMapping.schemaUrlPattern.replace('{version}', version);
+ if (!credential.type.includes(targetType)) {
+   return null;
+ }
+ const credentialString = JSON.stringify(credential);
+ // Try every mapping for this type; return the first that resolves a URL.
+ for (const mapping of mappings.filter((m) => m.credentialType === targetType)) {
+   if (!mapping.versionRegex) {
+     return mapping.schemaUrlPattern;
+   }
+   const versionMatch = credentialString.match(new RegExp(mapping.versionRegex));
+   if (versionMatch && versionMatch[1]) {
+     return mapping.schemaUrlPattern.replace('{version}', versionMatch[1]);
+   }
+ }
+ return null;

(b) Add 0.7.0 mappings (unified context → untp.unece.org/artefacts schemas) in
default-mappings.json, kept alongside the existing 0.6.x entries:

{
  "credentialType": "DigitalProductPassport",
  "versionRegex": "https://vocabulary\\.uncefact\\.org/untp/([^/]+)/context/",
  "schemaUrlPattern": "https://untp.unece.org/artefacts/schema/v{version}/dpp/DigitalProductPassport.json"
}
// ...and equivalently for dcc/dfr/dia/dte. Note the published filenames are NOT uniform:
// dfr/DigitalFacilityRecord.json, dia/DigitalIdentityAnchor.json, dte/DigitalTraceabilityEvent.json
// follow the Digital<Type> convention, but dcc is published as dcc/ConformityCredential.json
// (no "Digital" prefix), e.g.
// .../v{version}/dcc/ConformityCredential.json
// so each type needs its own explicit schemaUrlPattern; a single Digital{Type}.json template
// would 404 on dcc.

Heads-up for maintainers: the v0.7.0 DCC artefact breaks the Digital<Type> filename
convention used by the other four types — it is published as ConformityCredential.json,
not DigitalConformityCredential.json. Worth aligning upstream, or at least documenting,
to avoid the same 404 in other tooling.

(c) Update extractUNTPVersion to also recognise the unified context, e.g. add a
second regex https://vocabulary\.uncefact\.org/untp/([^/]+)/context/ and return the
first capture group that matches.

Verified: all five v0.7.0 schema artefacts return HTTP 200, with sha256:

  • dpp/DigitalProductPassport.json42c51943ab23547d5287899fd12b214b19b006c28d105a70ff390f8551b12653
  • dcc/ConformityCredential.json3fef068faece5975daa799c8f2d1db675c902f9a4e2c6d3c5fb6833799a73af1
  • dfr/DigitalFacilityRecord.jsona887dd1bd50e01d5aff6c7fb62b92d90b355f34378f2ff4cae2fdd1b032a614e
  • dia/DigitalIdentityAnchor.jsone26ec4543b29b353e720b345671ccf4926d472c2c5e1c65ecb6f5e9dd6e2a71d
  • dte/DigitalTraceabilityEvent.jsona1fa17b743d729e5cb7d1e7db689b87cc0bc655395a625b7ae6f9d195ecc84f9

Implementer-side note (Reeco, 2026-06-11)

Verified independently against real UNTP v0.7.0 DPPs (issuer did:web:ia.reeco.eco): after
adding a Reeco extension @context as the third array element, the credentials pass Tier 1
(valid JSON-LD + W3C VC schema) and pass the official UNTP v0.7.0 schema (Tier 2 diagnostic,
via the suite's own createAjvInstance + validateJsonAgainstSchema). The only remaining
failures are strictly suite-side — this issue — i.e. they are not a property of the
credentials under test.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions