Four-eyes framework
Table of Contents
1. Summary
The approval request already holds who asked, who decided, why, and where the request stands, for any kind of request. What it lacks for a gated refdata write is four things: a request that needs several different functions to approve it, a typed place to hold the change the request proposes, a gate in the write path so a gated write cannot skip the request, and one screen set that every kind of request shares. This note says how to add each without a new queue. The request stays the one record and the inbox stays the one queue. A part is one function's share of a decision. A pending change is one proposed write, held as a typed row of its entity until the request is approved. A policy says which acts are gated and which part decides each. A kind descriptor tells the web how to draw a kind. A new kind of request is then one policy row, one descriptor, and the operation that applies it.
2. Detail
2.1. What exists
| Part of the design | Built | Where |
|---|---|---|
| The request header, its states and its decisions | Yes | ores.inbox: approval_request, approval_request_state, approval_decision |
| A kind, with its decider permission, hold, comment and expiry rules | Yes | approval_kind, one permission and one approval count per kind |
| Four-eyes in the schema | Yes | A trigger refuses a decision by the person who asked, and one approval per person |
| Decide in one statement, with a version check | Yes | ores_inbox_decide_approval_request_fn |
| Expiry, a reminder sweep, notifications to deciders and to the asker | Yes | the expire and remind functions, notification_operations |
| A generic raise | Partly | inbox.v1.ops.raise_approval takes a kind and a reason and nothing else |
| A kind's detail and its apply | One kind | IAM's role request: ask_for_roles writes the request and its roles, and IAM applies approved ones by reconciliation |
| The queue, my requests, one request, its story | Yes | inbox.v1.ops.*, and the web pages under web/src/inbox |
| The web pages for any kind | No | The detail page draws roles, and the kind's name comes from a label table |
A kind with its own detail table and its own raise operation works today. It does not scale to the refdata journeys, where every gated entity would need both.
2.2. What the gated writes need that does not exist
| Need | Why the prototypes draw it | Gap |
|---|---|---|
| Several functions on one request | The waiting table lists Controller, Finance, Market Risk and Operations, each answering its own part | A kind has one permission and one count, so the request cannot say who must approve |
| An order between parts | A new book goes to the controller first | Nothing orders approvals |
| A held change | The decider reads what would change | A request holds only a reason, so there is nothing to read or apply |
| One request for several changes | A move, a rights grant and a new portfolio are one request | A request has no lines |
| Apply on approval, all or nothing | A request applies when the last part approves | Only IAM applies, for roles |
| A failed apply | A write can be stale by the time it is approved | No state says "approved and could not apply" |
| Checks before the decider sees it | The flat check, the ownership check, the stale version | Nothing runs a write without keeping it |
| A gate in the write path | A gated put must not succeed directly | The generated put applies at once |
| One screen set for every kind | The prototypes share their waiting, decide and declined screens | The pages draw roles and the role kind's words |
2.3. The design
2.3.1. Parts
A part is a function that must approve: controller, finance, market_risk, operations. A lookup of parts says, for each, the permission its decider needs and its place in the order. A request's parts are the distinct parts of its changes.
A decision records the part it answers, and the decide function approves the request only when every part has an approval from a person who holds that part's permission. A refusal from any part ends it. A part with an earlier place in the order cannot be answered until the earlier part has approved. The existing rules carry over unchanged: the person who asked never decides, one person approves a part once, a refusal needs a comment, and a decision names the version it saw.
The count the kind keeps, approvals_required, still serves the kinds with one
part, such as the role request, which names a single part with its permission.
2.3.2. The policy
The policy is data: a row says that an act on an entity, optionally narrowed to a set of fields, needs a named part. Changing who decides is a data change. The system tenant holds the rows, and a tenant may add a part to an act but never remove one the system names, so a tenant cannot loosen its own controls. A tenant without a Market Risk function does not edit the policy: it grants the part's permission to whichever role does that work, since a part is decided by a permission and not by a named job. The rows are the tables in Book types and the controls on each: the description of a book needs no part, the cost center needs finance, and so on. An act with no row is written directly.
2.3.3. The pending change
A request holds a list of lines. A line is one proposed write, held as a typed row of the entity it changes, in a table of its own. This is what the role request already does: its detail is a typed table keyed by the request, in the component that owns the kind, and nothing in the inbox is stored as a document. The pending table of a gated entity has the entity's own columns, with the same foreign keys, checks and lookup validation, plus the request, a line number, the operation (put or delete) and the version the maker read. A line holds no part. A put can change columns that two parts decide, so the request holds the parts, as the union the policy gives for the columns all its lines change. The pending table is generated from the entity's model, as the entity's other tables are, so a model change reaches the pending table in the same regeneration and the same migration. A request may hold lines for several entities, one typed table each.
An entity model opts in with :gated: true in its * Flags drawer. The code
generator then derives a second entity model, <entity>_change, beside the first,
and runs every existing template on it. The derived model has no natural keys and
no checks or insert-trigger rules, because those are the live row's rules and the
dry run runs them by calling the real write. It is read-only to clients: only the
raise operation writes a line. compass codegen generate derives it whenever it
generates the gated model, and a drift check fails when a derived model is stale.
A model can hold its changes before it enforces the gate. :pending_change: true derives
the same pending model and its stack and leaves the entity's writes direct. :gated: true
does both. A junction can carry either flag: its pending model holds the two halves as plain
columns.
The queue and the request page need three further typed facts that no entity table can give: the parts a request needs and their order. They live in one small table keyed by the request and the part, written at raise.
2.3.4. Raising
One generated operation raises a request. Its message is typed: a reason, and for each gated entity a typed list of proposed writes, in the shape the entity's put already takes. It does four things in one transaction:
- It runs each write through the real service's database layer inside a savepoint and rolls the savepoint back, so every refusal the service would give, such as a level violation or a stale version, comes back to the maker at once, before anyone waits. The dry run covers database rows. A sequence value it used stays used. It publishes no event and sends no notification, because it calls the repository and validation layer and never the layer that publishes.
- It reads the policy for the columns each line changes and gives the request the union of the parts. A request with no part is refused here as "needs no request", so a caller writes it directly. The policy names the columns of the entity, and a line that creates a row changes every column.
- It writes the request, its parts and its lines.
- It tells the deciders of the parts that can be answered now: the first part in the order, or every part when the policy gives no order.
A preview operation does steps 1 and 2 and writes nothing. The web calls it from the review, so the review can show every change with its decider before the person confirms.
The checks a decider reads, such as that the book is flat, that the owner unit is in the ancestry chain, and that the row is still at the version the maker read, run against the live row and the typed pending row. They run at raise, so the maker sees a failure early, and again at apply, because the live row may have moved.
2.3.5. Ordered parts and refusals
A part later in the order cannot be answered, and so cannot refuse, until the parts before it have approved. A refusal from a part that can be answered ends the request. The notification goes only to the deciders of the parts that can be answered now.
2.3.6. Applying, as a workflow
The wait for a person stays a record, not a workflow. The engine cannot wait on a person: its states are pending, in progress, completed, failed, compensating and compensated, nothing suspends a run, and a wait is a bounded budget whose expiry is a failure (Waiting, deadlines and cancellation). A decision may take days. So the parts, the order, the decisions, the expiry and the reminders are the record's own state.
What happens after the last approval is a process, and the engine is built for it. The approval's change event starts an apply workflow for the request. Its steps are each idempotent and each states a deadline:
- Re-check. Run the checks again against the live row and each typed pending row, because the live row may have moved since raise.
- Apply. Apply every line in one transaction, through the same service code the direct write uses, as the approver with the request as the reason, as IAM does for roles. Each line is marked applied in the same transaction, so a line applies once.
- Tell. Tell the person who asked, and the deciders, that it applied.
The definition's failure policy is stop, as provisioning's is, because the work
that matters is one transaction that either happened or did not, and the recovery
wanted is a retry and not a rollback. A failure is one of two kinds, and the
difference decides what happens:
- A refusal from the service, such as a stale version or a validation rule, is
permanent. The apply step rolls the transaction back, moves the request to
apply_failedwith the line and the reason, tells the person who asked, and ends the run as failed. A refusal is never retried, because the answer cannot change. - An infrastructure failure, such as a service that is down, or a missed deadline,
is transient. The run stops with its completed steps kept, the request stays
approved, and the engine's retry verb resumes the run. A scheduled retry calls the
verb until the kind's window ends, and then the request moves to
apply_failedwith the reason "could not apply", so a request never retries forever.
This replaces the hand-written reconciliation the role request uses. The run shows on the same workflow screens as provisioning, so an operator sees an apply that is stuck and why. The role request can move onto the same workflow later.
apply_failed is terminal, like refused, withdrawn and expired. The maker
raises a new request from the current row, and the new request needs every part to
approve again, because an approval is a decision on one proposed row and the new
request proposes a different one. The request page offers "raise again", which
carries the lines forward re-based on the current row so the maker does not retype
them.
2.3.7. The gate
The gate sits in the generated service of a gated entity, below every handler that
serves it, so a script, a hand-written handler or another client meets the same rule. The
put, the batch put, the delete and the batch delete each ask the policy first. The
service compares the proposed row with the live row, and a row that is new changes every
column. The policy names a part for the act, or it does not. When it names none, the
write goes ahead as before. When it names a part and the write does not come from an
approved request, the service refuses with the typed result approval_required, which
carries the outcome denied, and writes nothing.
The apply is tied to a request, not to a caller. The applier tells the service which request it carries out. For each write the service checks, in the same transaction, that the request is approved and holds an unapplied line for exactly this entity, key, operation and version. Nothing the caller can set opens the gate: only rows in the database do. The request is never a field of a message a client sends. It is set in process, by the applier alone. The preview runs the real writes in a transaction it never commits, so it marks the service as a rehearsal and the gate lets those writes through. The service's own in-process save and remove methods take no request, so a gated entity refuses them: the protocol operations are the only way to write.
A gated junction has the same gate on its put, its batch put, its delete and its batch
delete. A link has no columns of its own, so the service asks the policy with none, and a
policy row that names no column covers the link. The line the request holds names the
link by its two halves. The policy names the junction by the name of its model, for
example party_counterparty.
A hand-written handler, such as a composite write, reaches the store through the same service, so it meets the same check. A write that does not use the service does not meet it. The SQL that publishes a dataset into the tables is one such write. Whether the policy should cover it is a separate decision.
2.3.8. States
Six states exist today:
| State | Meaning |
|---|---|
waiting |
Asked, not yet decided |
held |
Parked pending a question |
approved |
Decided yes |
refused |
Decided no; final |
withdrawn |
The person who asked took it back; final |
expired |
Nobody decided before the window closed; final |
apply_failed is added, and it is final. The decision types stay: approve, refuse,
hold, resume, withdraw. A part's answer is a decision carrying its part, so the
history shows who answered which part and when, with no new table of answers.
2.4. The common experience
Every kind of request is drawn by the same screens. A kind adds a descriptor and nothing else on the web:
| Descriptor part | What it gives the shared screens |
|---|---|
label |
The kind's name, from the catalogue |
summary |
One line for the queue and for my requests |
subject |
A link to the thing the request is about |
delta |
How a line is drawn: the field changes for a refdata line, the roles for a role request |
columns |
What the queue shows beyond the common columns |
The shared screens are the ones the prototypes drew:
| Screen | What it is | Replaces |
|---|---|---|
| Review panel | Every change with its badge, the notice, and the primary button that writes or raises | The review step of each journey |
| Request page | The parts and who must still answer, the delta, the checks, the comment, and approve or decline | RequestDetailPage, which draws roles |
| My requests | What I raised, with its state | MyRequests |
| Queue | What I may decide, oldest first, filtered by kind | RequestsPage |
| Story | Every event of one request | RequestStoryPage |
The shared screens are built. The descriptor registry is kinds.tsx under web/src/inbox,
and a kind adds one entry there. The request page, the queue, my requests and the story read
the kind's name and summary from it. The request page draws the parts
(PartsPanel), the delta (ChangeDelta) and the checks (ChecksPanel), and the review panel
(ReviewPanel) is what a screen that gates a write hands its proposed changes to. The
BFF serves the data in one shape for every kind: GET /api/requests/:id/changes and
/checks, and POST /api/book-changes/preview and /api/book-changes for the book.
The role request is drawn by the same screens, with its roles and what they would grant as
its own section. The gated-write screens that call the review panel come with their own
tasks.
A screen that gates a write does not draw any of these. It asks the preview, hands the proposed writes to the review panel, and navigates to the request page after a raise. The decision screen, the waiting state and the declined state in the five prototypes are the request page in three states.
2.5. Holding the pending change
The pending change is typed, and there is no document column. The data design allows no untyped or weakly typed data, and the role request already shows the way: a typed detail table keyed by the request, in the component that owns the kind. A pending table per gated entity follows that pattern. A document that held the serialised write would give up the checked shape the schema exists to give, and it would break when a release changed the protocol under a pending request. A typed pending table changes with the entity's model, in the same migration, so a request held across a release stays readable.
The cost is one generated table per gated entity: about twenty today, from the acts in the controls note, and up to about forty-five when every convention family is drawn. The entity's model already declares every column, key and lookup the pending table needs, so generation carries the cost and no table is written by hand.
2.6. Delivery
The work splits so each piece is usable and reviewable alone:
- Parts and policy. The parts lookup, the request's parts table, the policy table, parts on decisions, the decide function counting per part and in order, and a deciders-per-part notification. No refdata yet. Tested in SQL and on the handler.
- Generate the pending change. A codegen facet that makes, for an entity marked gated in its model, the typed pending table, the typed raise and preview messages, the dry run, the apply, and the typed read of a pending change. First entity: the book.
- Raise, preview and apply. The operations that combine the generated pieces:
one raise for lines of several entities, the apply workflow with its one-transaction
apply,
apply_failed, and the apply workflow with its transient and permanent failures. - The gate. The policy check in the generated service, the
approval_requiredresult, and the request-tied pass for the applier. - The common experience. The BFF routes for preview, raise and the request page, the kind descriptor registry, the review panel, and the request page replacing the role-only detail.
- The screens. One task each to move the book structure, counterparty, party, convention and tenor screens onto the review panel.
- The people. The Acme accounts and the roles for each part, which exists as a task.
3. See also
- Approval requests — the record this note extends.
- Implement four-eyes authorisation framework — the deferred item this answers.
- Book types and the controls on each — the acts, the deciders and the policy rows.
- Ask for more access — the first kind, which this generalises beyond.
- Notification requirements — how a decider is told a request waits.