Skip to content

Milestone: M3 — Issuing Functionality | SOW Reference: FR1 — extends M3-02 | Requirement Clarity: ✅ Root cause confirmed against the real VNF schema | Dev Status: 🟡 Planned — awaiting go-ahead to execute

M3-17 Issue Credential Form — Reconcile Fields Against the Real Credential Schema

Client feedback (2026-08-20)

"Issue Credential page: Review fields against the schema and separate mandatory/optional fields."

Why this exists

The Issue Credential form (ManualCredentialForm.tsx) shows a "Custom fields" section labelled as optional, but several fields inside it are individually marked "Required" — a visible inconsistency the client's feedback caught. Investigating the root cause turned up something more specific than a labelling bug: the form's per-field required/optional data doesn't actually come from Velocity's real schema for this credential type — it comes from an internal variablesSchema object that was never reconciled with the parts of the code that already match Velocity correctly. This doc records the confirmed root cause and the fix, for review before we touch code.

Ground truth: the real Velocity schema

EmploymentPastV1.1 is a real, currently-live Velocity Network Foundation credential type. Its authoritative JSON Schema is fetched directly by Velocity's own Credential Issuance Hub (CIH) at issuance time — there's no bundled copy anywhere in VNF's own SDK, because their architecture resolves it live:

  • https://registrar.velocitynetwork.foundation/schemas/employment-past-v1.1.schema.json (also mirrored, byte-identical, on the dev/staging registrar subdomains)

What that schema actually requires, top level: legalEmployer, role, startDate, endDate, recipient. Optional top level: description, employmentType, place, alignment. Nested: - legalEmployer (an Organization) — required name, place; optional identifier (DID), image - recipient (a PersonName) — required givenName, familyName - place — required addressCountry (2-letter ISO); optional addressLocality, addressRegion - startDate/endDateYYYY, YYYY-MM, or YYYY-MM-DD

This was independently cross-checked against a real signed sample credential in Velocity's own vendored SDK test fixtures (vnf-wallet-sdk-nodejs-main/packages/sdk/test/infrastructure/resources/valid/CredentialMocks.ts, JwtCredentialEmploymentPastFromRegularIssuer), which decodes to exactly this field set. @velocitycareerlabs/vc-checks (the installed Velocity package) was also checked — it only implements issuer-trust/revocation/tamper checks, not field-level schema validation, confirming schema enforcement genuinely happens server-side at CIH, not client-side in our code.

What's already correct in this codebase

Someone already did this reconciliation once — just not everywhere. In app/backend/src/issuer/entities/credential-template.entity.ts:

  • The Handlebars template for EMPLOYMENT_PAST_V1_1 (lines 220–249) correctly builds legalEmployer / role / startDate / endDate / recipient, and even has a code comment citing the exact schema URL and required-property list (lines 215–219).
  • requiredFields (lines 250–259) — firstName, lastName, jobTitle, startMonthYear, endMonthYear, organizationName, organizationDid, countryCode — is the internal variable-naming that feeds that template correctly (e.g. startMonthYear fills the schema's startDate).
  • The sibling EMPLOYMENT_CURRENT_V1_1 template (lines 284–337) has its variablesSchema correctly aligned with its own requiredFields — proving this pattern works when kept in sync. This is the one exception to fix elsewhere in this doc — EMPLOYMENT_PAST_V1_1 is the only credential type where it drifted.

The actual bug: variablesSchema for EMPLOYMENT_PAST_V1_1 was never updated

variablesSchema (credential-template.entity.ts:260–282) is a separate object that's supposed to describe the same fields as requiredFields, for the frontend to render as inputs. For EMPLOYMENT_PAST_V1_1 it doesn't match requiredFields, the template, or the real Velocity schema, on every count:

Problem Detail
Wrong field names Uses startDate/endDate — the template's Handlebars placeholders and requiredFields use startMonthYear/endMonthYear. Because the names don't match, these can never be filled in by the dynamic form section at all.
Missing fields entirely organizationName, organizationDid, countryCode, department are required by requiredFields/the template but have no entry in variablesSchema — so they never render as inputs (today they're silently filled server-side from the org/employee record in manual-credential.service.ts:549–557, which happens to work but means the issuer can never override them if the employee record is wrong).
Wrong required/optional flags endDate is marked required: false — the real schema requires it, and requiredFields (via endMonthYear) treats it as required too.
Fields that don't exist in this schema at all email, employeeId, phone, workLocation, supervisorName, supervisorEmail, supervisorTitle, salaryAmount, currency, salaryPeriod, performanceRating, skills, responsibilities, reasonForLeaving, eligibleForRehire are all present in variablesSchema but have no corresponding property anywhere in the real EmploymentPastV1.1 schema or the Handlebars template. email and employeeId are marked required: true. This looks like a generic HR-form field list that was pasted in before the template/requiredFields were corrected against Velocity's real schema, and never cleaned up afterwards.
Minor: one existing disagreement even between the correct parts requiredFields lists organizationDid as required; the real schema marks Organization.identifier (the DID) optional — only name and place are required on the employer. Worth deciding whether to keep requiring it in our own UI (reasonable — an org without a recorded DID can't be a legalEmployer in a Velocity credential anyway) or relax it to match the schema exactly.

Frontend consequence (ManualCredentialForm.tsx:368–373): the "optional fields" list shown in the UI is computed by filtering variablesSchema against requiredFields — since the two arrays barely share field names, most variablesSchema entries (including ones marked required: true on the entry itself) end up filtered into the "optional" bucket, producing the exact contradiction the client flagged (section says "optional", individual field says "Required").

Backend consequence (manual-credential.service.ts:617–656, validateCredentialData()): this doesn't check requiredFields or variablesSchema at all — it checks for credentialSubject.person/credentialSubject.employment, keys that don't exist in the real rendered shape (legalEmployer/recipient). Those if blocks never trigger, so pre-issuance validation is currently a no-op beyond "is this an object with a credentialSubject." The only real enforcement happening today is CIH rejecting a bad payload after the fact.

Proposed fix

  1. Rewrite variablesSchema for EMPLOYMENT_PAST_V1_1 to describe exactly the fields the template/requiredFields actually use — firstName, lastName, jobTitle, department, startMonthYear, endMonthYear, organizationName, organizationDid, countryCode, plus the genuinely optional schema fields we want to expose (addressLocality, employmentType). Required flags on each entry should exactly match requiredFields. Drop every field with no corresponding schema property (email, employeeId, phone, workLocation, supervisor*, salary*, performanceRating, skills, responsibilities, reasonForLeaving, eligibleForRehire) from the credential form — that data still lives on the Employee record and CSV upload (M3-03), it's just not part of what gets issued into this specific credential type.
  2. Frontend: derive both the section grouping and the per-field "Required"/"Optional" label from a single source (the corrected variablesSchema's own required flag) instead of the current two-array cross-check at ManualCredentialForm.tsx:368–373. Fold the hardcoded "core" fields (candidate, dates) into the same schema-driven rendering where practical, so the whole form is driven by one required/optional source rather than a hardcoded top half + a separately-computed dynamic bottom half.
  3. Backend: rewrite validateCredentialData() (manual-credential.service.ts:617–656) to actually check the compiled credentialData.credentialSubject against requiredFields/the corrected variablesSchema, instead of checking for person/employment keys that never exist. This gives a clean, specific "missing X" error before the CIH round-trip, instead of only finding out after submission.
  4. Repeat the same drift check for EMPLOYMENT_CURRENT_V1_1 and any other credential types added later, since this exact kind of silent divergence is what caused the bug here — worth a short comment or a test asserting variablesSchema keys are a subset of the template's Handlebars placeholders, so it can't happen again unnoticed.

Scope note

This only reconciles EMPLOYMENT_PAST_V1_1, the one MVP-enabled credential type currently exercised by the manual issuance form and CSV bulk issuance (M3-03). EMPLOYMENT_CURRENT_V1_1 is already internally consistent and needs no data change, only benefits from whatever generic frontend/backend fix comes out of steps 2–3 above.

Acceptance criteria

  • variablesSchema for EMPLOYMENT_PAST_V1_1 contains exactly the fields the template/requiredFields use, with required flags matching requiredFields and the real Velocity schema
  • The Issue Credential form's required/optional grouping and per-field labelling are driven from one reconciled source — no field can show "Required" under a section labelled "optional"
  • validateCredentialData() actually validates the compiled payload against the real required-field set and returns a specific, actionable error before calling CIH
  • Re-tested live: issuing an EmploymentPastV1.1 credential with a required field missing produces a clear in-app error, not a raw CIH rejection
  • M3-02 — manual issuance form this task file extends
  • M3-03 — bulk issuance path, reuses the same ManualCredentialService and is affected by the same validateCredentialData() fix
  • M3-18 — preview redesign, touches the same form