Skip to content

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 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):

  • nameorganization.name (only the platform-default’s hardcoded 'Emergent' name is, in effect, authored, and stays a constant for now).
  • image — the canonical /img/organization/:id URL (ADR-0035), already wired.
  • parentOrgorganization.parentOrganizationId already models the hierarchy → emit the parent issuer’s issuerUri() as a parentOrg URI ref (ProfileRef permits 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 signed EndorsementCredential, 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.

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).

  • No new column / no migration (Decision 2) — profileMeta already exists.
  • No person-name fields, no endorsement (Decision 3) — out of the org-issuer surface.
  • No parentOrg authoring — 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]]).
  • 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.
  • profileMeta gains a real, enforced shape (IssuerProfileMeta in packages/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 structured geo/person-Profile cases remain open, deliberately scoped out here.