Skip to content

3. Approve and loop

The human decides, we reconcile, we send, and the item goes back to the brain.

3.1 Two queues, not one

The review app has two separate queues, because the items in them fail differently.

Action queue Confirmation queue
Holds Drafts, job forms, items with no draft Closes under notify, acknowledgements
The human must Do something Glance, or not
If ignored for a day A customer is waiting Nothing happens
Sorted by Effective priority, which ages Recency
Expect it emptied Yes No

Mixing them is what makes people ignore both. Confirmations dilute the queue that matters, and a stale confirmation looks as urgent as a customer waiting on a quote.

Effective priority

Computed at read time, never written to the item.

effective = priority + age escalation + chase bump
Input Effect
priority The base, set by rules at 1.2.3
Hours waiting One level per configured interval
Chased Immediate bump, and it does not decay

Keeping it computed means priority continues to mean how urgent was this message, rather than drifting into how long has it sat there. Two different facts, and reporting needs both separately.

Age escalation caps one level below urgent. A genuinely urgent item must never be buried under an old trivial one.

A chase is evidence, not time. It is detected deterministically: an inbound message arriving on an item already awaiting_approval. Someone following up has told you they are waiting, which is stronger than any inference from the clock.

A chase invalidates the draft sitting there

The chase is a new message, so it triggers a brain call and the assessment may change. The draft was written before the customer chased, so it does not acknowledge the follow-up and reads as though nobody noticed.

It is regenerated, and the fact was_chased goes into the Draft prompt so the new version opens by acknowledging the delay rather than greeting them fresh.

Config
priority_age_interval How long before an item escalates one level
priority_age_cap The ceiling. Defaults to one below urgent
chase_bump How many levels an inbound chase adds

3.2 What the human sees

The action queue holds four kinds of item, and the screen differs.

Item The human does
A draft Reads, then approves, edits, or discards
A job form Fills the gaps, then approves
A close under required Confirms nothing was needed, or corrects the category
No draft Writes the reply themselves

Writing from scratch is editing an empty draft

The editor already exists, because approvers edit drafts. So a no-draft item opens the same editor, empty. There is nothing extra to build.

The real difference is not the editor, it is what the content needs:

Editing a draft Writing from scratch
Structure exists Yes, they tweak No
Formatting Light Possibly heavy
Attachments Rare Sometimes essential

Attachments are the genuine gap. Sending a certificate or a quote PDF from the review app means upload, storage and a send path for it. In their mail client it is drag and drop.

So both, with an escape hatch

Same editor For editing a draft or writing from empty
Open in their mail client Always available, for attachments or anything the editor cannot do
Default for no-draft items Their mail client, per tenant config

There is a reason to prefer in-app when they will use it: what they write is captured cleanly against the work item, which is training data for the Draft prompt. Going out to their mail client means reconciliation still finds the reply, and the link is looser.

3.3 Who can approve

Permission attaches to the mailbox, not to the tenant. A business may run several with different people on each, and someone with full authority on service@ may have none on accounts@.

Two dimensions:

Which mailbox You have access, or you do not
What you may do there Your role on that mailbox

Three roles

Role May
Owner Everything, including flagged items and tenant config
Approver Approve routine drafts. Not flagged ones
Assistant Confirm closes and clear the confirmation queue. Cannot approve anything that sends

Assistant is the one worth having. It is exactly the delegation a small business wants: a junior clears the noise, and anything reaching a customer stays with someone senior.

The boundary already exists

The Owner and Approver split is attention_reason being null or set, so the check is one field rather than a permission matrix.

That is also why flagging matters. Marking a legal threat for attention and then letting anyone approve the reply would defeat the flag entirely.

Owner Approver Assistant
Routine draft ✅ ✅ ❌
Flagged draft ✅ ❌ ❌
Job form ✅ ✅ ❌
Close under required ✅ ✅ ✅
Confirmation queue ✅ ✅ ✅
Tenant config ✅ ❌ ❌

Three roles is a starting point. Adding a fourth later is easy. Removing one a client already has is not.

3.4 One person at a time

Two people working the same queue will open the same draft. Whoever clicks second either double-sends or hits a confusing error.

An item is claimed when opened, with a short timeout.

flowchart LR
    O["Someone opens<br/>the item"] --> C["Claimed by them<br/><i>for claim_timeout</i>"]
    C --> D{"They act?"}
    D -->|"yes"| A(["Approved, edited<br/>or discarded"])
    D -->|"no"| R(["Released back<br/>to the queue"])
Situation Behaviour
Someone else opens a claimed item Sees who holds it. May read, may not act
The claim expires Released, and anyone may take it
An Owner needs it back May force-release, and that is recorded

Optimistic locking was the alternative, meaning first approval wins and the second person sees "already handled". Less to build, and in a shared inbox that collision happens constantly rather than occasionally, which is why every shared-inbox tool claims instead.

Config
claim_timeout How long a claim holds. Minutes, not hours

3.5 Approval does not expire. Drafts go stale

An earlier version of this design had approvals expiring, which raised an unanswerable question: expire into what? There is no honest status for "we proposed, nobody looked".

The question dissolved once ageing existed.

Problem Solved by
Nobody has looked at it Ageing. It climbs the queue until it is at the top
The draft no longer fits Staleness. It is regenerated

Two different problems, and expiry was a third mechanism doing neither well.

Staleness

A draft written on Monday and approved on Thursday goes out saying things that were true on Monday. Ageing makes the item more visible and does nothing about that.

So after draft_staleness, the item goes back to the brain and the draft is regenerated, with the delay as a fact.

flowchart LR
    W["Draft waiting"] --> A{"Past<br/>draft_staleness?"}
    A -->|"no"| K(["Keep waiting.<br/>Ageing lifts it"])
    A -->|"yes"| B["Back to the brain"]
    B --> R(["Regenerated,<br/>acknowledging the delay"])
    classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
    class B llm

This is the same mechanism as a chase, triggered by time rather than by the customer. Both mean "this draft no longer fits the situation", and both regenerate rather than flag.

Nothing auto-approves, ever, and nothing expires into a dead-end state. The item stays in the queue the whole time, climbing.

The threshold has a floor and a ceiling

Every regeneration is two model calls, not one. Classify runs again because the situation changed, then Draft.

Value Why
Minimum 24 hours Below that you regenerate faster than most people check a queue
Recommended 48 to 72 hours Long enough to be a genuine staleness signal
Maximum 7 days Beyond that the draft is useless anyway, and the item needs a human rather than a fresh draft

An item sitting a week on a one-day threshold costs seven regenerations, fourteen model calls, on something nobody has looked at.

When regeneration should stop

A threshold alone does not bound the cost, because an item can sit for a month.

The counter is calls since the last human action, not calls in total.

A total count separates none of the three cases

Calls Why a total fails
A loop High Broken
An abandoned item High Ignored
A six-week negotiation High Perfectly healthy

A quote negotiated over two months is a legitimate item with many exchanges, and halting it would break a working conversation.

Counting since the last human action resolves it: a real conversation resets the counter constantly and never approaches the threshold.

Telling a system problem from an abandoned item

Both look identical on the counter. They differ entirely in where the item is sitting.

Where it sits What is consuming calls
Loop assessing. It never settles The system, repeatedly
Abandoned awaiting_approval. It settled and waits Staleness regeneration only

An abandoned item is not looping. It reached a decision, produced a draft, and stopped. Its counter moves only because we regenerate on a schedule, which is our own doing rather than a malfunction.

calls since last human action > max_calls_since_human
  └─ status == awaiting_approval  →  abandoned    (theirs)
  └─ anything else               →  call_ceiling (ours)

Confirming signal, if certainty is wanted: an abandoned item's calls arrive at a predictable interval, because staleness is a scheduled trigger. A loop's do not. Gaps matching draft_staleness mean we are regenerating on time rather than something spinning.

What happens at the ceiling

attention_reason Goes to Alerts Console
call_ceiling Review app Yes. A bug
abandoned Review app No. Not our fault

Alerting ourselves about a client ignoring their own queue would be wrong.

Either way
Regeneration Stops. No further spend
Position Top of the queue. Ageing already put it there
The draft Whatever was last generated. Stale, and better than nothing
Status Still awaiting_approval. It has not been resolved

abandoned is a churn signal, not a technical one

A client with twenty abandoned items is not using the product, whatever their volume numbers say. That shows months before they cancel, and it belongs in commercial reporting rather than health.

Two different timeouts, never confuse them

Expires after Means Then
Claim Minutes Someone opened it and wandered off Back in the queue, unchanged
Staleness Hours or days The draft no longer fits the situation Regenerated
Config
draft_staleness How long before a waiting draft is regenerated. Floor 24 hours, ceiling 7 days
max_calls_since_human Regenerations before the item is flagged and regeneration stops

3.6 What a human can do with a draft

Four responses, and three of them are not "discard". Every one is information, and throwing it away is how the system stops improving.

Two separate questions, answered independently.

Question Field
Was the read wrong? What was wrong corrected_category, optional
What happens now? The outcome status

They combine four ways, and all four are legitimate:

Read wrong? What now Meaning
No Regenerate The read was right, the words were wrong
No Let me do it Fine, I would rather write it
Yes Re-run corrected Best case. We get a label and produce a proper draft
Yes Let me do it We get a label, they handle it

So "wrong classification" is not an outcome, it is a reason that may accompany any outcome.

The three outcomes

The human means Requires Signal
Approve Send it Nothing Approval rate
Regenerate Try again A reason, mandatory Generation quality
Let me do it I will write this myself Nothing Workflow preference

"Should not have been drafted at all" is not a fourth outcome. If it needed no reply, the classification was wrong, which is the other field.

Regenerate needs a reason, and not for data collection

The reason goes into the Draft prompt. "Too formal", "do not promise Tuesday", "they already know this" is what makes the second attempt different from the first.

Without it you pay two model calls to produce roughly the same draft, so the field is mandatory.

flowchart LR
    R["Regenerate"] --> W["Reason,<br/>mandatory"]
    W --> C{"Category<br/>correction?"}
    C -->|"yes"| B["Back to the brain,<br/>reason + correct category"]
    C -->|"no"| B2["Back to the brain,<br/>reason only"]
    B --> D(["New draft"])
    B2 --> D
    classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
    class B,B2 llm

Same path as staleness and a chase: something changed, so the draft is regenerated rather than patched.

The category correction is optional

Making it mandatory taxes the person for our benefit. Recommended during onboarding, when the classifier most needs the signal, and the app should say so rather than hiding the trade-off.

A correction can accompany any outcome, including "let me do it". Someone who tells us the category was wrong and then writes the reply themselves has still given us the more valuable of the two things.

What each produces

Outcome Work item Feeds
Approve sent_by_us after reconcile Approval rate
Regenerate Stays awaiting_approval, new draft The Draft prompt
Let me do it closed_by_human once they reply The reply, if store_human_replies
Any of the above, with corrected_category Unchanged The eval set. A labelled example from someone who knows

The last row is the valuable one, and it is orthogonal to the outcome. A human correcting a category has produced exactly what eval needs, as a by-product of using the product rather than as a labelling exercise.

The superseded approval record

Regenerating replaces a draft that already had an approval record. Both are kept.

The first record status: superseded, with the reason
The new record status: pending

Only one is ever pending, so "which is live" is unambiguous.

Keeping the superseded one matters because the sequence is the evidence: what we proposed, why a human rejected it, and what we proposed instead. Deleting it would leave the approval chain showing only the version that happened to be accepted.

Same applies when staleness regenerates, though there the reason is stale rather than a human's words.

3.7 Approval is a trigger, not a wait

The human's approval is itself what starts the next n8n run. A pending approval consumes nothing: no polling, no held connection, no scheduled sweep.

flowchart LR
    W(["Item sits<br/>awaiting_approval"]) --> H["Human approves<br/>in the review app"]
    H --> T["That write fires<br/>a new n8n run"]
    T --> R{"Reconcile"}

3.8 Attachments

document_request is the highest-volume, most automatable category, and it is nearly worthless without attachments. "Your CoC is ready, log in and find it" is not the reply anyone wants.

So attachments are not an edge case for scratch-writing. They are what makes the best category actually work.

flowchart LR
    C["Category:<br/>document_request"] --> L["Look up the document<br/><i>by job, in their system</i>"]
    L --> A["Attach to the draft"]
    A --> H(["Human confirms<br/>it is the right one"])

The human confirming it is the right document is the point. We fetched something by job reference, and a wrong attachment sent to a customer is worse than no reply.

Needs Notes
A document lookup capability Fetch by job from their system. Separate from the mail connector
An attach operation on the mail connector Provider-specific, same as create_reply_draft
Attachments as draft_reply parameters Rather than a separate action, since the human still approves one draft

Not built. Client-zero has no certificates to fetch. Tracked as OI-082.

This changes the value of mailbox mode

In review_app mode we attach the document ourselves and the human confirms. In mailbox mode the draft lands in Outlook, and the attachment has to land with it or the human is downloading and attaching by hand, which is most of the work we were saving.

The mail connector's draft operation must therefore support attachments in both modes.

3.9 Reconcile before sending

Did the human already reply themselves? Someone answers from their phone, or rings the customer, while the item sits waiting.

Look for any outbound message on this work item sent after the item was created, matching by Message-ID and References rather than by the provider's thread ID.

Found Outcome
No prior reply Send. status: sent_by_us
A prior reply Abort. status: closed_manual, and record it

The check runs immediately before sending, not on a schedule. A sweep every fifteen minutes still leaves a window in which we send anyway.

There is a residual race: the human could send in the gap between our check and our send. It cannot be eliminated, only narrowed. If it ever proves to matter, the stronger version is check, send, then verify the thread again and flag if two replies now exist.

Detecting is not the same as storing

Two different things, and only one is optional.

Purpose Optional?
Detect the reply exists Reconciliation, so we do not send twice No. Correctness depends on it
Store the body Training data for the Draft prompt Yes, per tenant

Reconciliation always records the Message-ID and timestamp, because without them we would send a duplicate. Whether we keep what they wrote is store_human_replies, off by default. A client opts in to us keeping their staff's writing rather than opting out.

Off means slower improvement, and clients should be told

A human writing the reply we could not is the richest training signal available, precisely because it covers the cases where our draft was missing or wrong.

With storage off, the Draft prompt still learns from approved edits, and it improves more slowly for that client. Worth saying plainly rather than letting them discover a quality plateau.

Same distinction as everywhere else: the metadata is operational, the content is theirs.

This applies to mailbox mode generally, not just to the reconcile case. When a client works in their own mail client, every reply they send is one we detect and may or may not store.

3.10 Record what we sent

After sending, write a message row with direction: outbound carrying the Message-ID the provider returns.

This is the step whose absence breaks the second exchange

Without it, the customer's reply to our reply references a Message-ID we have never seen, so it opens a new work item.

Roughly half of all conversations would break, and only on the second exchange, which is a miserable thing to diagnose.

3.11 Back to the brain

n8n reports the outcome and the brain decides whether anything else is needed.

The call always happens

The obvious optimisation is to skip it. After a successful send the answer is almost always close_item, so why spend a call being told that?

Because n8n cannot know when a decision is needed. Skipping the call means n8n deciding that nothing is undecided, which is exactly the judgement it is not equipped to make.

So the brain is always asked, and the call usually costs nothing:

Brain call Model call
Outcome is unambiguous Yes No. Rules resolve it
Genuinely undecided Yes Yes

The brain reaches Decide, rules say close, and no model is invoked. brain_calls increments, model_calls does not.

That is the split those two counters exist for, and it means "one model call per brain call" was always a ceiling rather than a rule.

What n8n passes

The brain is stateless and reloads the work item anyway. What n8n adds is what just happened, which the item does not yet record: which action ran, whether it succeeded, and any failure.

Deciding is the brain's job. Bookkeeping is not

Recording that a send succeeded is a write, not a decision. Putting a model call in front of a database update does not make the brain more authoritative.

Who
Record the outcome, update status and current_stage n8n
Write the outbound message row n8n, it holds the connector
Store approved examples for retrieval n8n, a write. Read back at Draft time
Alert aggregates The alerting evaluator, on a schedule (ADR-0034)
Decide what happens next Brain
Change config The Console. A human

The brain never writes config

If it could, it would be editing the rules it operates under. The two-writers rule (ADR-0006) exists precisely to prevent that: Ansible at bootstrap, the Console thereafter, and the brain reads only.

A human correcting a category produces a labelled example. It does not silently change the taxonomy.

Every outcome

Outcome n8n records Brain decides Model call Console
Draft sent sent_by_us, outbound message row close_item No
Acknowledgement sent Draft sent, item retained Close or hold, per acknowledgement_closes_item No
Reconcile aborted the send closed_manual, their reply recorded close_item No
Human handled it themselves closed_by_human close_item No
Closed with no action closed_no_action Already decided No
Flagged, no draft, human wrote it closed_by_human close_item No
Connector failed Nothing. Retry Not called No Incident
call_ceiling Flagged, regeneration stops Not called No Incident
abandoned Flagged, regeneration stops Not called No Advisory, aggregated
Regenerate requested Approval superseded New draft Yes
Category corrected, re-run Approval superseded, correction stored Re-assess with the correction Yes
Staleness fired Approval superseded New draft Yes
Job created job_id set Close, or draft a confirmation Maybe
Customer replied New message row, item rejoined at 1.1 Full assessment Yes

Nine of fourteen cost nothing. The loop is cheap because most outcomes are unambiguous, and the brain's involvement is a rules pass rather than a generation.

Reconcile aborted the send means we were about to send and found the human had already replied themselves. Sending ours would be a second reply to the same question.

Three worth noticing

call_ceiling and abandoned share a field and split on audience. A loop is a bug and interrupts us. A client ignoring their own queue is not a fault, so it reaches us as an advisory through the alerting mechanism, aggregated over a window. One abandoned item is nothing. Twenty is a churn signal and a phone call (ADR-0034 §4).

An acknowledgement may or may not close the item, per tenant. Some clients treat it as "we have it, we will handle this offline" and the item's job is done. Others want it held open until a real answer goes out. Defaults to holding open, because closing an item whose customer never got a real answer is the failure to avoid.

Job created is the only genuinely open one. Whether a confirmation email follows is a tenant preference rather than a fixed answer.

Where the loop ends

close_item The brain says nothing further is needed
Ceiling hit Regeneration stops, item stays in the queue flagged
Customer replies It does not end. The item rejoins at 1.1

A closed item is not finished. A reply arriving reopens it, because the customer is continuing the same conversation (OI-079 covers what happens to the fields, still undefined).

What happens on a reply

The customer replies. Its References header matches a Message-ID we already hold, so it joins the existing work item as the next turn.

The brain then has what it decided and what was sent, rather than starting cold. Quoted history in the reply is unreliable (trimmed, collapsed, or dropped by mobile clients) and in any case contains only the words sent, never the assessment, the approval or the action taken. That state is ours and lives on the item.

Job resolution re-runs every turn, not only when the item is created, so a reply naming a different site can re-link it (ADR-0032 §6g).

Reopening a closed item

A reply arriving on a closed item reopens it, because the customer is continuing the same conversation.

A reopen is a new work item that inherits history. Fresh assessment, fresh config, fresh flags. The only thing carried forward is the conversation itself, which is why it is not literally a new row: the customer is replying to something, and threading has to keep working.

The rule for anyone reading later

If you would ask it of a new item, ask it again on reopen.

No need to reason field by field. Everything clears except what identifies the conversation, and what is needed to count it honestly.

Carried forward Cleared or restamped
The thread and its messages status, back to assessing
first_closed_at, reopen_count closed_at, cleared
attention_reason, cleared
config_version, catalogue_version, restamped to current

Customer and job are re-resolved anyway, every brain call.

It is one piece of work, not two

Counting a reopen as a second item would inflate handled volume with what is really one exchange.

first_closed_at is what makes that countable. Clearing closed_at alone would lose the fact the item was ever closed, so double-counting becomes undetectable afterwards. Keeping both lets reporting say "handled once, reopened twice" honestly.

Clearing attention_reason loses nothing

A legal_risk flag from three weeks ago should not carry over to a routine follow-up. The new message is judged on its own.

The history survives regardless. decision is append-only, one row per brain call, each holding the assessment at that moment. So the original flag lives on the decision that set it, and the field on the work item is only the current state.

Restamping is not an exception to pinning

Config is pinned for the life of a work item (ADR-0006), and a reopen starts a new life. The rule is intact.

Pinning exists so a rule changed mid-conversation does not retrospectively alter behaviour, which is right over days. A reopened item may be months old, and running it on config nobody remembers is staleness rather than consistency.

And every decision row records the version it ran under, so the audit trail is per decision rather than per item. The item's config_version is simply what the next call will use.


Back to the flow, or see the full stage list.