Registry — UI functional specification¶
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 |
Register search¶
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 active → suspended → expired → revoked, 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.
Related¶
- Registry — domain model, rules, and decisions
- Front-end — Angular conventions the implementation follows
- Design language — KADLS tokens
- Application platform — error contract