Bundle Service
The OcaBundleService is the entry point for working with OCA bundles in the EDK. It coordinates three things: parsing raw OCA JSON into a structured ParsedOcaBundle, processing the parsed form to flatten every overlay into a single typed OcaProcessedBundle, and verifying the bundle's SAID integrity. The interface is intentionally small; the heavy lifting happens in the dedicated parser and processor classes.
interface OcaBundleService {
fun parseBundle(bundleJson: JsonObject): ParsedOcaBundle
fun processBundle(bundle: ParsedOcaBundle): OcaProcessedBundle
fun parseAndProcess(bundleJson: JsonObject): OcaProcessedBundle
fun verifySaid(bundle: ParsedOcaBundle): Boolean
}
The default implementation, DefaultOcaBundleService, lives in lib-data-oca-impl and is wired through Metro at session scope. Code that needs to consume OCA bundles injects the interface and gets the default; tests can substitute their own implementation as needed.
Bundles also drive OID4VCI attribute provisioning — see Compact attribute provisioning.
Parsing
Parsing turns the raw bundle JSON into a ParsedOcaBundle:
data class ParsedOcaBundle(
val bundleSaid: String,
val captureBaseSaid: String,
val ocaVersion: String,
val attributes: Map<String, String>,
val overlays: List<ParsedOcaOverlay>,
val rawJson: JsonObject,
)
data class ParsedOcaOverlay(
val type: String,
val language: String?,
val said: String,
val captureBaseSaid: String,
val payload: JsonObject,
)
The parser reads the bundle SAID (d or digest), the capture base, and every overlay. It accepts both the v1 array layout and the v2 object layout for overlays, and it accepts both d and digest as the SAID field name on individual elements. Unknown fields are preserved in the original rawJson so writers and round-trippers do not lose information.
val parsed = ocaService.parseBundle(bundleJson)
println("Bundle SAID: ${parsed.bundleSaid}")
println("Capture Base SAID: ${parsed.captureBaseSaid}")
println("OCA Version: ${parsed.ocaVersion}")
parsed.attributes.forEach { (name, type) ->
println(" $name: $type")
}
parsed.overlays.forEach { overlay ->
println("Overlay: ${overlay.type}, language: ${overlay.language ?: "(global)"}")
}
A ParsedOcaBundle is the right type to keep around if you need the original overlay structure (for example, to display the bundle's overlay layout in an admin UI, or to feed it into the native persistence service unchanged). For consumers that just want the flattened metadata, the next step is processing.
Processing
Processing walks the parsed overlays and produces a single typed result:
data class OcaProcessedBundle(
val captureBaseSaid: String,
val bundleSaid: String,
val ocaVersion: String,
val attributes: Map<String, String>,
val characterEncodings: Map<String, String>,
val formatPatterns: Map<String, String>,
val standards: Map<String, String>,
val mandatoryAttributes: Set<String>,
val cardinalities: Map<String, String>,
val entryCodes: Map<String, List<String>>,
val units: Map<String, String>,
val sensitiveAttributes: Set<String>,
val metaDisplays: Map<String, OcaMetaDisplay>,
val labels: Map<String, Map<String, String>>,
val descriptions: Map<String, Map<String, String>>,
val entryValues: Map<String, Map<String, Map<String, String>>>,
val groups: Map<String, String>,
)
Most fields are keyed by attribute name. The ones that depend on locale (labels, descriptions, entryValues, metaDisplays) carry the locale as the inner key.
val processed = ocaService.processBundle(parsed)
processed.metaDisplays.forEach { (locale, display) ->
println("[$locale] ${display.name}: ${display.description ?: ""}")
}
processed.labels.forEach { (attribute, perLocale) ->
perLocale.forEach { (locale, label) ->
println(" $attribute [$locale]: $label")
}
}
println("Mandatory: ${processed.mandatoryAttributes}")
println("Sensitive: ${processed.sensitiveAttributes}")
processed.entryValues.forEach { (attribute, perLocale) ->
perLocale.forEach { (locale, codeToDisplay) ->
codeToDisplay.forEach { (code, display) ->
println(" $attribute [$locale]: $code -> $display")
}
}
}
The OcaOverlayProcessor that backs processBundle is defensive about overlay shapes. When the conformance overlay uses the OCA v2 object form ({"given_name": "M", "family_name": "O"}) the result is the set of mandatory attribute names; when it uses the legacy array form (a plain list of mandatory attribute names) the result is the same set. The same kind of dual-shape handling applies to entry_code, cardinality, and attribute_unit. Wrongly shaped values do not throw; they are skipped, so a single malformed overlay does not break the entire processing run.
Combined Parse-and-Process
parseAndProcess is the convenience method for the common case where you do not need the intermediate ParsedOcaBundle:
val processed = ocaService.parseAndProcess(bundleJson)
Use this when you are reading a bundle for display, validation, or conversion to credential design. Use the explicit two-step form when you need the parsed bundle for SAID verification, native persistence, or any flow that wants the original overlay structure.
Cardinality Parsing
OCA cardinality strings are stored as strings in OcaProcessedBundle.cardinalities so the processor stays neutral. The static helper OcaOverlayProcessor.parseCardinality turns them into a structured (min, max) pair when consumers need numeric bounds:
| Input | Result |
|---|---|
"3" | (3, 3) |
"1-5" | (1, 5) |
"0-*" | (0, null) |
| invalid | null |
The credential design mapper uses this helper to convert OCA cardinality into the design system's ClaimCardinality type; consumers can do the same when they need typed bounds.
Field-Name Constants
The OCA v2 specification uses a small set of canonical JSON field names (d, type, capture_base, overlays, attributes, attribute_labels, attribute_information, attribute_formats, attribute_conformance, attribute_entry_codes, attribute_entries, attribute_cardinality, attribute_unit, language, name, description). The constants for those names are exposed as OcaFieldNames. Code that reads or writes OCA JSON should reference the constants rather than string literals so the wire format stays in sync with a single source of truth.
The OCA spec version constant (OcaSpec.VERSION_2 = "2.0.0") is the default version written by builders and the fallback assumed by readers when no type version segment is present.
Tips for Bundle Authors
When constructing OCA bundles programmatically (for example, in a credential issuance pipeline that emits an OCA companion bundle alongside every credential), the order is: build the capture base, compute its SAID, build each overlay with the capture base SAID pinned in captureBaseSaid, compute each overlay's SAID, then build the bundle wrapper with the capture base embedded and the overlay list filled in, and compute the bundle SAID last. The OcaSaidVerifier.applySaid helper takes care of the placeholder-then-recompute step at each level.