Record the decision before the side effect, then append the outcome. This is the difference between a log and a ledger.
Here is the shape with verification built in:
async function decide(input: ApplicantInput) {
const d = await evaluate(input); // typed facts + deterministic policy
const record = await decisions.insert({ ...d, outcome: "unknown" });
if (d.verdict !== "allow") return record; // hold, block, or route to review is still a decision
const result = await core.applyChange({
subject: input.subjectRef,
expectedVersion: input.version,
idempotencyKey: record.decision_id,
});
return decisions.appendOutcome(record.decision_id, {
state: result.version === input.version + 1 ? "verified" : "unknown",
resultRef: `core-event:${result.eventId}`,
});
}
Three properties follow. A crash between insert and apply leaves a durable record with state unknown, so reconciliation can find it. A duplicate retry reuses the idempotency key, so the person is not double-charged or double-rejected. And a hold verdict is itself a recorded decision, which matters because regulators increasingly ask about the decisions that were stopped, not only the ones that went through.
For the review phase itself, make approval an appended event rather than an edit to the original record. The reviewer sees the task summary, evidence references they are entitled to open, policy reason codes, the expected change, and an expiry. Their approval is a new immutable entry that names them, their role, and the time. If the review times out, that is also an event. Silent escalation to auto-approval is the single most common way a "human in the loop" claim collapses under examination.
Under GDPR Article 22, a person has protections against decisions based solely on automated processing that produce legal effects or similarly significant effects, and where the exceptions apply, the controller must implement safeguards that include the right to obtain human intervention, express a point of view, and contest the decision (Article 22 GDPR). The ICO's guidance turns this into an operating checklist: give people information about the processing, provide simple ways to request human intervention or challenge a decision, and carry out regular checks that the system works as intended (ICO on automated decision-making).
That maps to four record fields that must be externally renderable: the reason code with its plain-language sentence, whether a human authorized the decision and in what mode, the appeal path, and the appeal deadline. Everything else (evidence digests, internal model versions, reviewer employee ID) stays internal.
Two design rules follow. First, the external view is a projection, not a copy: render it from the record at request time so it always reflects the policy version in force when the decision was made. Second, keep the appeal deadline in the record, not in a downstream ticketing system. If the notice clock and the record clock are separate, they will diverge, and you will discover the divergence in a complaint rather than a dashboard.