Approval requests
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. Why a record, and not a waiting workflow
- 2.2. The record
- 2.3. The schema
- 2.4. The lifecycle
- 2.5. Rules the record enforces for every kind
- 2.6. How an approval is applied
- 2.7. What is generic and what a kind adds
- 2.8. Where it lives
- 2.9. The queue
- 2.10. The caution about trades
- 2.11. What notifications add
- 2.12. Decisions
- 3. See also
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.
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:assignfor 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
- One component.
ores.inboxholds the approval record and the notifications. - 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.
- 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
- An end-of-day FX rate set must be complete and independently verified — completes: a sign-off this could carry.
- Four-eyes framework — how this record grows to hold several deciding functions, a pending change, a gate in the write path and one screen set for every kind.
- Ask for more access — the first kind: a member asks for a role.
- Operational authorisation queue — the trade queue whose rules this record takes.
- Operational authorisation — why authorisation is metadata, and the four-eyes principle.
- Implement four-eyes authorisation framework — the deferred item this design answers.
- Workflow waiting and deadlines — why a person's decision is not a workflow wait.