Skip to content

Registry — UI functional specification

Screen-level brief for the Registry module

Status: Draft for design Version: 0.1 Date: 2026-08-12 Scope: Everything the Registry backend currently supports, plus the endpoints it must gain.

This is the design brief for the Registry module of the supervisory workspace. It defines every screen, state, and action, and binds each one to a live endpoint. It is written to be handed to a designer or a generative UI tool without further explanation.

Domain model and rules: Registry. Front-end conventions: Front-end. Visual tokens: Design language.

1. Who uses it and what they are doing

A supervision officer at a securities regulator. On any given day they are:

  • looking up a firm they just got an email about — by name, licence number, or registration number;
  • checking whether a firm's licence is currently active, and what it is licensed to do;
  • checking who the directors are, and when someone was appointed or resigned;
  • onboarding a newly licensed firm, or loading the existing firm book from a spreadsheet;
  • correcting master data after a firm notifies a change.

They are not database administrators. They never see a UUID. They work on a wide monitor, often with a spreadsheet open next to the browser, and they are comparing values between the two.

2. Vocabulary — what the interface calls things

The database says party. The interface must not.

Domain / table What the UI says
Organisation party Firm
Individual party Person
Party role (director, auditor, …) Officer (collectively: Directors & officers)
Actor party on a role Person holding the office
license_register row Licence
license_register_status_history Status history
license_import_batch Import
party.status Registry status (distinct from licence status)

"Party" and "dossier" appear nowhere on screen.

3. Navigation

Registry is one module of an eventual multi-module workspace. Only build what exists. No disabled nav items, no placeholder counts, no divisional workspace switcher — none of that is backed by data, and shipping it as furniture is worse than an honest, small navigation.

Kovent
└── Registry
    ├── Firms          → /registry/firms
    ├── People         → /registry/people
    └── Imports        → /registry/imports

Sidebar: fixed 240px, product mark at top, single "Registry" group, signed-in user at the bottom. No visible "Collapse sidebar" label — if a collapse affordance is wanted at all, it is an unlabelled chevron in the sidebar's top-right corner, and it is not part of V1.

Header per screen: breadcrumb, page title, and the primary action for that screen, right-aligned.

Content: full width, capped around 1600px, gutters of 24–32px. Not a centred narrow column.

4. Screens

S1 — Sign in

The first thing anyone sees. Every route below is behind an auth guard; an unauthenticated visit to any URL lands here and returns to the original URL after sign-in.

Route /sign-in
Layout Split: left panel is brand (Kovent mark, product line, muted regulatory imagery or flat gold-tinted surface); right panel is a centred card
Card contents Kovent wordmark, "Sign in to continue", a single primary button Sign in with Fordsworth ID, and a small footer line naming the environment
Mechanism Keycloak OIDC redirect (PKCE). There is no username/password field in this app — credentials belong to Keycloak
States Idle · Redirecting (button shows spinner, disabled) · Error (identity provider unreachable — inline critical banner with retry) · Signed-out confirmation (when arriving from an explicit sign-out)

No "forgot password", no "create account" — both live in Keycloak.

S2 — Firm register

The most important screen in the module. This is a register; the register is a list.

Route /registry/firms
Title Firms
Primary action Register firm (button, gold)
Secondary action Import (link/ghost button → S6)

Search bar — full-width, at the top, focused on load. Single input, placeholder "Search by firm name, licence number or registration number". Debounced; results update in place.

Filters — a row of dropdowns beneath the search: Licence status (active, suspended, revoked, expired), Licence category, Registry status (active, inactive), Country. Active filters render as removable chips. A "Clear all" appears when any filter is set.

Results table — dense, sortable, keyboard-navigable. One row per firm:

Column Notes
Firm Trading name in primary weight; legal name beneath in muted small text if different
Registration no. Mono
Licences Category label(s); if more than two, "Asset Manager +2"
Licence status Status pill — tinted surface, not a saturated fill
Expiry Nearest expiry date; amber text if within 60 days, critical if past
Registry status Muted pill; only shown when not active

Row click opens the firm (S3). Row hover reveals a right-aligned overflow menu: Open, Add licence, Add officer.

Footer — result count ("124 firms") and pagination.

States

State Treatment
Loading Skeleton rows (8), not a spinner
Empty — no firms at all Centred empty state: icon, "No firms on the register yet", two buttons — Register firm, Import a spreadsheet
Empty — no search matches "No firms match acme", with a Clear search link. Never the same empty state as above
Error Inline critical banner above the table, with retry. The table chrome stays

S3 — Firm record

Route /registry/firms/{firmId}
Breadcrumb Firms / {trading name}

Header block — firm identity, always visible:

  • Trading name as h1; legal name beneath if different.
  • Status pills: registry status, and one pill per licence status.
  • Key facts on a single line, mono where they are identifiers: registration number, tax number, entity form, country, incorporation date.
  • Right-aligned actions: Edit details, and an overflow menu with Change registry status, Add licence, Add officer.

Tabs

Tab Contents
Overview Two-column summary: registered details on the left; primary contact, primary address, and licence summary on the right. Each card has an inline Edit affordance
Licences One card per licence: category, number (mono), status pill, issued / expiry, effective window. Each card has Amend and Change status, and expands to reveal status history as a vertical timeline (status, date, who, reason)
Directors & officers Table: Person, Role, Appointed, Ended, and — for beneficial owners — ownership %. Default filter is Current only, with a toggle for Include past. Ended rows are muted with a strikethrough date. Row actions: Amend, End appointment. Header action: Add officer
Contacts & addresses Two stacked sections. Contacts grouped by type (email, phone, mobile) with a "Primary" badge; addresses as cards by type (registered office, postal, branch) with a "Primary" badge. Add / edit / remove per item
Identities Table of formal identifiers: type, value (mono), issuing country, issued, expires, primary badge, status. Expired rows flagged. Add / edit / remove

States — Loading: header skeleton plus tab skeletons. Not found: a full-page 404 with a Back to firms link, not a red toast. Error: critical banner in the content area with retry.

S4 — Register a firm

Route /registry/firms/new
Pattern Single scrolling page with numbered sections and a sticky right-hand progress rail — not a wizard. Officers routinely have the paperwork in front of them and want to key it in one pass
Section Fields Required
1. Firm details Legal name, trading name, entity form (select), country of incorporation (select), incorporation date Legal name
2. Registration & tax Registration number, registration date, tax number, tax registration date, VAT number, tax status (select)
3. Licence Repeatable block: licence category (select), licence number, status (select), issued date, expiry date, effective from, effective to Category, number, status, effective from — if a licence block is present at all
4. Contacts Repeatable: type (select), value, purpose, primary toggle Type, value
5. Addresses Repeatable: type (select), line 1, line 2, city, province, postal code, country (select), primary toggle Type, line 1, country
6. Directors & officers Repeatable: either pick an existing person (typeahead) or enter a new one inline (title, given name, family name, date of birth, nationality), plus role type (select), appointed from, appointed to Role type, appointed from

Every section after the first is optional and collapsed by default with an Add affordance — the register-then-enrich path must be one field and one click.

Footer bar, sticky: Register firm (primary) and Cancel. Validation errors summarise at the top and anchor to the offending field. On success, navigate to S3 with a success toast.

Duplicate registration number or licence number returns 409 — surface it as an inline field error on the specific field ("A firm with this registration number already exists — view it"), never a generic toast.

S5 — People

Routes /registry/people, /registry/people/{personId}, /registry/people/new

Same shape as firms, reduced. List: name, national ID / passport (mono), nationality, offices held count, registry status. Record: identity header, then tabs for Offices held (which boards this person sits on), Contacts & addresses, Identities. Registration form: title, given name, middle name, family name, initials, date of birth, gender, nationality, plus optional identities, contacts, addresses.

S6 — Imports

Routes /registry/imports, /registry/imports/{batchId}

Import list — table of past imports: filename, uploaded date, uploaded by, total / created / matched / errors as compact counts, status. Primary action New import.

New import — a drag-and-drop dropzone accepting .csv, .xlsx. Below it, a permanently visible expected columns reference (this is what stops half the support load):

Column Required Notes
licence_number Yes
licence_category Yes
effective_from Yes YYYY-MM-DD
status No Defaults to active
registered_number No Used to match an existing firm — matched first
tax_number No Fallback match key
legal_name Conditional Required when no existing firm matches, since a firm will be created
trading_name, entity_form, incorporation_date, country_of_incorporation, registered_date, tax_registered_date, vat_number, tax_status No Used only when creating a new firm
issued_date, expiry_date, effective_to No YYYY-MM-DD

Offer a Download template link that emits exactly these headers.

Import result — the payoff screen. Four summary tiles across the top: Total, Created, Matched, Errors (critical tint when non-zero). Beneath, a per-row table: row number, outcome pill (created / matched / error), the firm it resolved to (linked), the licence, and the error message for failed rows. Filter chips to show Errors only. Errors must be readable at a glance — this is a spreadsheet of a hundred rows and the officer needs the four that broke.

Upload is synchronous today, so show a determinate-feeling progress state while the request is in flight and warn against navigating away.

5. Action dialogues

Everything below is a focused modal or right-hand sheet launched from the firm record. Each has a title, minimal fields, a primary and a cancel, and inline validation.

Action Fields Endpoint
Edit firm details Organisation fields PUT /api/parties/{id}/organisation
Edit person details Individual fields PUT /api/parties/{id}/individual
Change registry status Status (select), reason PUT /api/parties/{id}/status
Add licence Category, number, status, issued, expiry, effective from/to POST /api/parties/{id}/licenses
Amend licence Category, dates, attributes — not status PUT /api/parties/{id}/licenses/{licenseId}
Change licence status Status (select), reason (textarea), effective to POST /api/parties/{id}/licenses/{licenseId}/status
Add officer Existing person (typeahead) or new person inline; role type; appointed from/to; attributes (ownership % for beneficial owners) POST /api/parties/{id}/roles
Amend officer Role type, dates, attributes PUT /api/parties/{id}/roles/{roleId}
End appointment Effective to (date) POST /api/parties/{id}/roles/{roleId}/terminate
Delete officer record Confirmation only — labelled Delete record (correction) and visually separated from End appointment DELETE /api/parties/{id}/roles/{roleId}
Add / edit / remove contact Type, value, purpose, primary POST/PUT/DELETE /api/parties/{id}/contacts[/{contactId}]
Add / edit / remove address Type, lines, city, province, postal code, country, primary POST/PUT/DELETE /api/parties/{id}/addresses[/{addressId}]
Add / edit / remove identity Type, value, issuing country, issued, expires, primary, status POST/PUT/DELETE /api/parties/{id}/identities[/{identityId}]

Ending an appointment vs deleting one is a real distinction. A director who resigned is history the regulator must keep; a director keyed in by mistake is a correction. The UI must make ending the obvious path and deleting the deliberate, confirmed, rarely-used one.

All mutating endpoints return the full updated profile, so every dialogue closes by replacing the record state — no refetch, no stale panel.

6. Existing API surface

All under /api. Every mutation returns PartyProfile unless noted.

Method Path Purpose
GET /parties/{id} Full profile: core, subtype, identities, contacts, addresses, licences (with status history), roles
POST /parties/organisations Register firm — accepts nested licences, roles, contacts, addresses, identities in one call (→ 201)
POST /parties/individuals Register person, with nested children (→ 201)
PUT /parties/{id}/organisation · /individual · /status Amend core details, change registry status
POST/PUT/DELETE /parties/{id}/identities[/{identityId}] Identities
POST/PUT/DELETE /parties/{id}/contacts[/{contactId}] Contacts
POST/PUT/DELETE /parties/{id}/addresses[/{addressId}] Addresses
POST/PUT/DELETE /parties/{id}/roles[/{roleId}] Assign / amend / hard-delete an officer
POST /parties/{id}/roles/{roleId}/terminate End an appointment (effectiveTo)
POST /parties/{id}/licenses Record a licence (409 on duplicate number)
PUT /parties/{id}/licenses/{licenseId} Amend licence facts
POST /parties/{id}/licenses/{licenseId}/status Status transition with reason, appends history
POST /parties/import Multipart CSV/Excel upload → LicenseImportResult (→ 201)
GET /parties?q=&type=&status=&licenceStatus=&licenceCategory=&country=&page=&size=&sort= Register search → PagedResult<PartySummary>
GET /reference · /reference/{set} Coded value sets for every select

q matches display name, legal and trading name, registration number, tax number, licence number, and identity value — case-insensitive, partial. type is individual or organisation. sort is name, -name, recent, or -recent. size defaults to 25 and caps at 200. An unknown type or sort is a 400; no matches is a 200 with an empty content array.

Each row carries nullable organisation and individual blocks (same convention as PartyProfile) plus a licence aggregate:

Field Meaning
licence.count How many licences the party holds
licence.categories Distinct categories, sorted
licence.status The one status the row shows. Precedence is activesuspendedexpiredrevoked, so a firm holding both an active and a revoked licence reads as active — the question a list answers is "is this firm authorised for anything"
licence.nextExpiryDate Earliest expiry across the party's licences; drives the expiry warning

individual.officesHeld counts roles where the person is the actor, which is the "sits on N boards" figure the people list shows.

Reference sets

GET /reference returns every set in one response so a client loads its selects once. GET /reference/{set} serves one, resolving the name case-insensitively; an unknown set is a 404.

Each set carries a provisional flag. false means the values are confirmed by Registry or already stored by the record: party-status, tax-status, licence-status, contact-type, address-type, title. true means the values were inferred and will change: licence-category, entity-form, role-type, identity-type, gender, country. Provisional sets are safe to build screens against — the shape is right — but nothing downstream may treat them as a closed domain.

Note that POST /parties/{id}/roles accepts either actorPartyId or a full nested actorIndividual — so "add a director" supports both picking an existing person and creating one inline, exactly as S4 §6 and the Add officer dialogue require.

Errors are RFC 7807 Problem Details. Render detail as the human message; map 400 to field-level errors, 404 to a not-found page, 409 to an inline duplicate error on the conflicting field.

7. Endpoint gaps

# Endpoint Gates Status
1 Register search — paged summary rows S2, S5, person typeahead in Add officer ✅ Delivered — GET /parties, contract in §6
2 Reference sets for every select Every select in S3–S6 ✅ Delivered — GET /reference, provisional flags in §6
3 GET /parties/import/batches and /batches/{batchId} S6 import list and result license_import_batches / license_import_rows are persisted but unreadable. The result exists only in the POST response — navigate away and a hundred-row outcome is gone
4 GET /parties/{id}/roles?direction=held S5 Offices held ⬜ Profile roles answer "who are this firm's officers". The inverse — which boards a person sits on — has no query. PartySummary.individual.officesHeld gives the count but not the list

Items 1 and 2 were the blockers; the register and every select can now bind to real data. Item 3 is needed before Imports is more than a one-shot upload. Item 4 affects one tab of the People screen.

Licence-expiry sort is also absent: sort covers name and recency only, because ordering by nearest expiry needs a join sort. Add it if the register proves it is wanted.

8. Cross-cutting rules

Density. This is an internal tool for people who read tables all day. Compact rows, real column alignment, numbers and identifiers in Geist Mono and tabular figures. Not a marketing page.

Every list has four states — loading (skeleton), empty-by-nature, empty-by-filter, and error. The two empty states are never the same component.

Never show a UUID. Ids belong in the URL. If a support case genuinely needs one, it goes behind a copy-id affordance in an overflow menu.

Dates render as 12 Aug 2026. Relative time only for audit timestamps. Never raw ISO.

Status is a tinted pill, never a saturated fill: active success tint, suspended warning, revoked critical, expired muted, inactive muted.

Destructive actions require confirmation naming the thing being destroyed, and use the critical token only on the confirming button.

Keyboard. / focuses search. Arrow keys move the table selection, Enter opens. Every dialogue closes on Escape and traps focus.

Accessibility. Every control has an accessible name; the table is a real <table> with scoped headers; status is never conveyed by colour alone — the pill carries text.

9. Explicitly out of scope for this version

Portal accounts and mandates · document links to Vault · licence grant workflow (that is Licensing) · risk ratings · returns and obligations · dashboards and charts · multi-tenant workspace switching · dark mode.

10. Brief for generative UI

Design a supervisory workspace for a financial-services regulator: a Registry module for maintaining the register of licensed firms.

Users are supervision officers on wide desktop monitors, working through firm records and spreadsheets. Information density matters more than whitespace. The tone is institutional and quiet — a system of record, not a SaaS dashboard. No gradients, no illustrations, no marketing.

Visual language: light background of cool grey; white surfaces; hairline cool-grey borders; a single muted gold as the brand and primary-action colour used sparingly; status shown as soft-tinted pills (green, amber, red, grey) never saturated fills. Type is Plus Jakarta Sans, with Geist Mono for identifiers, reference numbers, and any figure in a column. Generous line height in prose, tight rhythm in tables. Rounded corners around 8px, shadows almost absent.

Screens: (1) a split-panel sign-in with a single SSO button; (2) a firm register — prominent search, filter chips, dense sortable table of firms with licence status pills and expiry warnings; (3) a firm record — identity header with status pills and key facts, then tabs for overview, licences, directors & officers, contacts & addresses, identities; (4) a single-page registration form with numbered collapsible sections and a sticky submit bar; (5) a spreadsheet import screen with dropzone, an expected-columns reference, and a results view with created/matched/error summary tiles over a per-row outcome table.

Navigation is a fixed 240px sidebar with the product mark, a short Registry group (Firms, People, Imports), and the signed-in user at the foot. Show only real functionality — no placeholder counts, no disabled items.

Show every list in all four states: loading skeleton, empty, no-search-results, and error.