The issuer Profile's descriptive fields are *authored* into `issuer_profile.profileMeta`, *derived* where the data already exists, and emitted into every OB/CLR Profile node
Status: accepted (2026-06-22) — design locked this session (storage / authz / field-scope / ADR
decisions taken in review). The build is the next increment; no DB migration is required (Decision 2),
so it does not add to the reset+apply already queued for Anton
([[feedback_db_migrations_anton_domain]]). Work lands on develop
([[feedback_develop_branch_no_new_branches]]); pre-launch, no legacy ([[feedback_no_legacy_pre_launch]]).
Builds on ADR-0035 (the canonical entity image — the issuer Profile.image this ADR sits beside;
image was Profile field #1, this authors the rest), ADR-0015/0016/0017 (the per-org issuer Profile,
its VC/JOSE proof, and platform-custodial signing keys — the issuer_profile row and the
provision/rotate surface this extends without touching), and ADR-0028 (the conformance overlay —
the OB-ISS / CLR-ISS issuer requirements this strengthens). The field denominator is conform-ed’s
open-badges/v3_0 ProfileSchema (CLR 2.0 bundles the same OB bodies).
The trigger
Section titled “The trigger”The organization image (ADR-0035) was the first authorable field of what the specs call the issuer
Profile — but it was one field. The OB 3.0 Profile carries far more, and the credential the
exporter signs emits almost none of it. buildIssuerProfile produces:
{ id, type: "Profile", name, image? } // name = coalesce(organization.name, 'Emergent')against a spec Profile of { id, type, name, url, email, phone, description, image, address, otherIdentifier, official, parentOrg, … }. Every descriptive field beyond name+image is absent — and
there is no surface anywhere to author them (orgs are selected in admin, never edited; the
issuer_profile row carries an empty profileMeta: {} jsonb built for exactly this and used for
nothing). A certifier inspecting an issued badge sees an issuer with no homepage, no contact, no
description. This ADR makes the descriptive Profile authorable.
Decision 1 — the fields live on issuer_profile, not organization
Section titled “Decision 1 — the fields live on issuer_profile, not organization”Forced, not chosen. The platform-default issuer is a null-org issuer_profile row (independent
learners’ credentials are signed by it). It has no organization to hang url/email/description
on. issuer_profile is the single row that exists for both an org issuer and the platform-default,
so it is the only correct home. (This is also why the image — which can key on an organization id —
is a different mechanism: the image is polymorphic across offering/achievement/organization via
entity_image; the issuer descriptive fields are issuer-specific and must cover the org-less issuer.)
Decision 2 — store in profileMeta (jsonb) behind a shared zod schema; no new column, no migration
Section titled “Decision 2 — store in profileMeta (jsonb) behind a shared zod schema; no new column, no migration”A single IssuerProfileMeta zod schema (in packages/domain, so the API and the admin-console UI
validate against one definition) shapes the existing issuer_profile.profileMeta jsonb. We do not
add typed columns.
Rationale: these fields are read whole at export and written whole on save — never queried, filtered, joined, or indexed. A jsonb blob validated at the read/write boundary is exactly the right strictness; typed columns would buy queryability we never use and cost a regenerated migration + a DB apply. The column already exists and was created for this. (Contrast ADR-0035, where the image got its own table — because it is polymorphic across three entity kinds and needs object-key + LWW + erasure semantics. Different needs, different storage; this is the lighter case.)
The trade accepted: jsonb is not schema-enforced by the database, so a hand-written row could hold a
malformed profileMeta. The IssuerProfileMeta parse on every read (and the totality of the export
assembly) contains that — a bad blob fails closed at export, it does not emit garbage into a signed
credential.
Decision 3 — author the org-issuer-relevant fields; derive the ones we already hold
Section titled “Decision 3 — author the org-issuer-relevant fields; derive the ones we already hold”Authored (the full org-issuer-relevant Profile surface):
| Field | Shape |
|---|---|
url |
a single https URL |
email |
an email address |
description |
free text |
phone |
free text |
address |
structured { type:"Address", addressCountry?, addressCountryCode?, addressRegion?, addressLocality?, streetAddress?, postOfficeBoxNumber?, postalCode? } (we omit geo — lat/long is not org-issuer-relevant) |
otherIdentifier |
zero-or-more { type:"IdentifierEntry", identifier, identifierType ∈ IDENTIFIER_TYPES } |
official |
free text (name of an accountable official) |
Derived, never authored (authoring would duplicate a source of truth):
name—organization.name(only the platform-default’s hardcoded'Emergent'name is, in effect, authored, and stays a constant for now).image— the canonical/img/organization/:idURL (ADR-0035), already wired.parentOrg—organization.parentOrganizationIdalready models the hierarchy → emit the parent issuer’sissuerUri()as aparentOrgURI ref (ProfileRefpermits a bare URI). Free enrichment, no UI, no risk of the authored value drifting from the org tree.
Explicitly out of scope — bounding what “everything authorable” means:
- The Profile’s person-name fields (
familyName,givenName,additionalName,patronymicName,honorificPrefix/Suffix,familyNamePrefix,dateOfBirth) — those describe a Profile that is a person (a credential subject rendered as a Profile), not an organization issuer. Nonsensical on an org; not offered. endorsement/endorsementJwt— an endorsement is a separate signedEndorsementCredential, not a descriptive field; it is its own future lane, not a text box on this form.
Decision 4 — emit through the existing resolve → assemble path, into every Profile node
Section titled “Decision 4 — emit through the existing resolve → assemble path, into every Profile node”resolveIssuerProfile already returns { id, name }; it gains meta: IssuerProfileMeta (parsed from
profileMeta). buildIssuerProfile (wire) spreads every present authored field — plus the
derived parentOrg — into the Profile node, alongside the name+image it already sets. This applies to
both the single OB AchievementCredential export and each inner body of a CLR — the same two
sites the image threads through. The null-org CLR envelope issuer stays minimal (it already
carries no image; parity is deliberate — the transcript-level issuer is the platform, the rich
per-org Profiles ride on the inner achievements).
Every field is optional and additive: an unset field is simply absent, so this enriches the credential without changing any currently-emitted bytes or tripping the export totality guard.
Decision 5 — authoring is org-or-platform admin; provisioning stays platform-admin
Section titled “Decision 5 — authoring is org-or-platform admin; provisioning stays platform-admin”Parity with the image authz (ADR-0035): an org admin manages their own org’s issuer
descriptive Profile; a platform admin manages any. The null-org platform-default issuer is
platform-admin-only (there is no org to be an admin of). Issuer provisioning and key rotation
(issuer-service, ADR-0015/0017) are untouched and remain platform-admin-only — minting/rotating
Ed25519 keys is a sharper privilege than editing a homepage URL. So the split is: who you are signs
(platform-custodial keys, platform-admin to provision); how you present is yours to edit (org-admin
descriptive fields). A new PUT /workflow/issuer-profile/:organizationId authoring endpoint enforces
this; reads ride the existing public issuer-Profile document.
What it feeds
Section titled “What it feeds”OB-ISS / CLR-ISS issuer Profile completeness. The image already moved OB-ISS-8 / CLR-ISS-3; the
descriptive fields harden the issuer requirements that concern a well-formed, contactable,
identifiable issuer Profile rather than flipping a brand-new gap — and, concretely, they turn the
dossier’s issuer Profile from name+image into the full spec object a certifier expects to dereference.
The overlay notes are updated when the increment lands (no new schema MUST, so no cited-metric move
— guide-curated issuer reqs, same as the image).
What we are NOT doing
Section titled “What we are NOT doing”- No new column / no migration (Decision 2) —
profileMetaalready exists. - No person-name fields, no endorsement (Decision 3) — out of the org-issuer surface.
- No
parentOrgauthoring — derived from the org tree (Decision 3). - No change to provisioning or key custody (Decision 5) — descriptive only.
- No backfill — every unset field is simply absent; pre-launch, nothing to migrate ([[feedback_no_legacy_pre_launch]]).
Consequences
Section titled “Consequences”- The issuer Profile becomes authorable and spec-complete for an organization issuer, from one org-admin panel (the image panel’s sibling), with zero DB migration — buildable and validatable without waiting on a reset+apply.
profileMetagains a real, enforced shape (IssuerProfileMetainpackages/domain), shared by the API write path and the admin-console form — one definition, two consumers.- The jsonb-not-columns trade means the database does not enforce the Profile shape; the zod parse on every export read is the guard, and it fails closed.
- A future endorsement lane (
EndorsementCredential) and the structuredgeo/person-Profile cases remain open, deliberately scoped out here.