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.
| 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.