Approval requests

Table of Contents

1. Summary

Some work waits on a person's decision: a member asks for a role, a trade waits for Operations to authorise it, a change to a counterparty waits for a second authoriser. Each of these is the same thing underneath: somebody asks, somebody else decides, and both the ask and the decision are kept. This document proposes one record and one queue for all of them, the approval request, rather than a table per feature or a workflow instance that waits.

The record holds who asked, what kind of request it is, what it is about, why, its state, and who decided, when and why. A kind is registered by the component that owns the thing asked about, and that component applies an approval through its own operation, so the record never writes another component's data. The queue is one read filtered by kind, and four-eyes, required comments and expiry are rules of the record, not of each feature.

The first two kinds are the role request of Ask for more access and the deferred four-eyes authorisation framework. The trade's operational authorisation queue is the third, with one caution set out below.

2. Detail

2.1. Why a record, and not a waiting workflow

The journey first held a role request as a workflow instance that waits. The workflow engine cannot wait on a person: its states are pending, in progress, completed, failed, compensating and compensated, nothing suspends a run, and the waiting design treats a wait as a bounded budget whose expiry is a failure. A person's decision is neither: it may take days, and an unanswered request is not a failure of anything.

A record is the smaller change. It needs no engine state, it is listed and filtered like any other entity, and its history is the audit. A workflow can still start one, and continue when the decision is made, by watching the record's change event; the record does not need the workflow.

2.2. The record

Field What it holds
id The request's identity
tenant_id The tenant it belongs to; row-level security scopes every read
kind What is asked, from a registered list: iam.role_grant, trading.operational_authorisation, …
(detail) The kind's own detail, in the kind's own table keyed by the request's id
requested_by, requested_at Who asked and when; a system may ask, as an automated feed does
reason Why they ask, in their words
state Where it is: see the lifecycle
decided_by, decided_at, decision_comment Who decided, when and why
expires_at When an unanswered request closes, or none

The record is bitemporal like the other IAM and reference entities, so every change of state is a version and the history is the audit trail an auditor reads.

2.3. The schema

The model follows data-oriented design: each fact in one column of one table, keys stated, and no column that holds a document. Nothing is stored as JSON.

approval_and_notification_schema.png

Figure 1: The proposed schema: approval requests, the role request kind, and notifications.

ores_inbox_approval_kinds_tbl, a lookup of the kinds:

Column Key What it holds
code PK iam.role_grant, trading.operational_authorisation
description   What the kind is for
decide_permission_code FK to ores_iam_permissions_tbl The permission a decider needs
allows_hold   Whether held is a state this kind uses
comment_on_approve   Whether an approval needs a comment
expires_after_days   The days after which an unanswered request closes, or null when it does not expire
approvals_required   How many approvals, each by a different person, close a request as approved; one for most kinds

ores_inbox_approval_request_states_tbl, a lookup of the states, with which are final.

ores_inbox_approval_requests_tbl, the header every kind shares:

Column Key What it holds
id PK The request
tenant_id FK to the tenant Row-level security scopes every read
kind_code FK to the kinds What is asked
state_code FK to the states Where it is
requested_by FK to ores_iam_accounts_tbl Who asked
requested_at   When
reason   Why, in their words
expires_at   When it closes unanswered, or null
version, valid_from, valid_to   The bitemporal history every IAM entity keeps

A request is decided by many decisions, not one: a kind may need two approvers, and a hold, a return to waiting, a refusal and a reversal are each a decision of their own. Each is a row:

ores_inbox_approval_decisions_tbl:

Column Key What it holds
id PK The decision
request_id FK to the requests What it decides
decision_code FK to ores_inbox_approval_decision_types_tbl Approve, refuse, hold, resume, reverse
decided_by FK to ores_iam_accounts_tbl Who decided
decided_at   When
hold_reason_code   Why it was parked, from the hold reason lookup; null on every decision that is not a hold
comment   Why

The kinds, the states and the decision types are lookups the system tenant holds, so a request or decision in any tenant checks its codes against them through their validation functions, as an account checks its status.

The request's state_code is the state the decisions have reached. A check, in the decision's insert trigger, refuses a decision by the person who asked, so four-eyes is a rule of the schema and not only of the service, and a unique key on (request_id, decided_by) for approvals keeps one person from counting twice towards approvals_required.

Each kind adds its detail table, owned by the component that owns the kind and keyed by the request:

ores_iam_role_grant_requests_tbl Key What it holds
request_id PK, FK to the requests The request this is the detail of
account_id FK to ores_iam_accounts_tbl Who would hold the roles
ores_iam_role_grant_request_roles_tbl Key What it holds
request_id PK part, FK to the role grant requests The request
role_id PK part, FK to ores_iam_roles_tbl One role asked for; a request may ask for many
ores_marketdata_curve_publishes_tbl Key What it holds
request_id Natural key, FK to the requests The request this is the detail of, and its single use
bootstrap_config_id, as_of, party_id   The subject: the curve, the instant and the party the request authorises
published_by, published_at   Empty while the request waits; the publisher and the instant once it is applied

The screen of the first build asks for one role at a time; the entity does not assume it. What a screen shows together is the journey's composition of these tables, not a column added to one of them.

The kind's own table is also where "one open request per subject" is checked, because only it knows what the subject is.

A hold reason is a lookup of its own, ores_inbox_hold_reasons_tbl, held by the system tenant and read by every tenant's deciders. A hold names one of its codes and never free text alone: the decide operation refuses a hold whose code the lookup does not hold, and answers with the codes it does. The codes are the operational authorisation queue's — the questions an operator waits on, the handover markers between regional teams, and the exception flags that cause a hold — so the held-over report can group by them. A hold taken before the lookup existed keeps its comment and carries no code, and still reads.

2.4. The lifecycle

State Meaning Exits
waiting Asked, not yet decided approve, refuse, hold, withdraw, expire
held Parked pending a question; a suspension, not a refusal approve, refuse, back to waiting
approved Decided yes; the owning component has applied it reverse, with a comment
refused Decided no none
withdrawn The person who asked took it back none
expired Nobody decided before expires_at none

Three rules come from the operational authorisation queue and hold for every kind:

  • A refusal is final. A refused request cannot be approved; the route is a new request. Reversing a terminal decision is evidence that something went wrong.
  • An approval is reversed, not edited. Undoing an approval is its own step with a comment, and the owning component undoes what it applied.
  • Concurrency is a notice, not a lock. Two people may open the same request; the second decision is refused by its version, and the screen says who decided.

held is optional per kind: a role request has no use for it, a trade does.

2.5. Rules the record enforces for every kind

  • Four-eyes. The person who decides is not the person who asked. A kind may require more: several approvals from different people, or a decider from another desk.
  • Who may decide. Each kind names the permission a decider needs, such as iam::roles:assign for a role request, so the queue a person sees is the kinds they may decide. A request that names parts is decided by part instead, and each part names a permission of its own, so such a request is listed for the people who may answer a part whose turn has come.
  • Comments. A kind states which decisions need a comment. A refusal always does, because the person who asked reads it.
  • One open request per subject. A second request for the same subject while one waits is refused, so a person cannot flood the queue.

2.6. How an approval is applied

The role request applies by reconciliation, as described below. For refdata changes the four-eyes framework applies an approved request through a workflow instead, because an apply is a process the engine retries and shows, while the wait for a person stays a record.

The record never writes another component's data. When a request is approved, the decision publishes the request's change event, and the component that owns the kind applies it through its own grant: IAM grants the role as the approver, carrying the request as the grant's reason, so the grant has the same audit tail as any other.

The event is a nudge, not the instruction. IAM applies approvals by reconciliation: at start and on every change to a request, it asks which approved request roles an account does not yet hold, in every tenant not terminated, and grants them. A missed event, a repeated one, or an approval made while IAM was down all converge to the same roles, and a grant that fails is tried again on the next run rather than turning the request back to waiting.

2.7. What is generic and what a kind adds

Set against the operational authorisation queue, the role request and the deferred four-eyes item, almost all of the queue is the same for every kind.

Part Generic What a kind adds
The record and its history Who asked, when, why, state, who decided, when and why Its detail table, which names what the request is about
The lifecycle Waiting, approved, refused, withdrawn, expired; a refusal is final; an approval is reversed with a comment Whether held is allowed, and its reasons (a trade's handover markers)
Four-eyes The decider is not the asker A stronger rule, such as another desk or a second decider
Who may decide A permission per kind The permission's code
Comments A refusal always needs one Which other decisions need one; a trade needs one on every decision
Concurrency The second decision is refused by its version Nothing
Expiry A request closes when expires_at passes The default window, or none
One open request per subject Refused while one waits What "the same subject" means
Applying the decision The decision publishes a change event The operation that applies it, in the owning component
Raising a request A person asks, or a system asks on their behalf Whether the system raises it (a trade on arrival) and whether it can decide on its own (auto-authorisation)
The queue One paged read: kind, state, subject, age; oldest first; scoped by tenant and the decider's permissions Its own columns from its detail table, its grouping, highlighted changes
Reports Time waiting, decisions by person, what stayed open overnight A kind's own reports, such as held-over trades
Notifications Tell the deciders a request waits; tell the asker the decision The words

So a kind is a small declaration: its name, its subject and detail table, the permission that decides it, its comment and hold rules, its expiry, and the operation that applies an approval. The record, the lifecycle, the rules, the queue, the reports and the notifications are written once.

2.8. Where it lives

In one new component, ores.inbox, beside the notifications: the things that wait for a person and the things a person is told. It belongs to no business area, and every component may register a kind. Decided on 2026-10-04.

2.9. The queue

The queue is one paged read of the record, filtered by state and subject and by what the caller may decide, ordered oldest first, and limited by row-level security. A caller may decide a request whose kind names a permission they hold, or a request that names a part whose permission they hold, while that part is open. Both cases are one statement, so the total it reports is the number of rows its pages walk. A screen shows a kind's own columns from its detail table. The trade queue's grouping, highlighted changes and reports are views over this read.

2.10. The caution about trades

A trade's operational authorisation is not a request somebody makes: every trade arrives needing it, its states (unseen, held over, authorised, denied) are metadata of the trade, and auto-authorisation decides many without a person. Folding it in means one request per trade, raised by the system on arrival. That fits the record, but its volume and its reports are the trade queue's own, so the trade kind is designed with Operations before it is built.

2.11. What notifications add

A request waits until somebody looks. The queue's count on the menu is the only signal today. Telling the decider that something waits, and telling the person who asked what was decided, is the notification subsystem's first use, and the record's change events are what it listens to.

2.12. Decisions

  1. One component. ores.inbox holds the approval record and the notifications.
  2. A typed table per kind. The record is a common header; each kind keeps its detail in a table of its own, keyed by the request's id, in the component that owns the kind. The role request's detail (the account and the role) lives in IAM. A new kind is a schema change, which buys a checked shape.
  3. Open: the expiry default, and whether a reversal of an approval is a state of the same request or a request of its own kind.

3. See also

Emacs 29.3 (Org mode 9.6.15)