# WhatsApp

TaxPilot AI is not a customer-facing chatbot. It attaches to a real person's
existing WhatsApp account and watches **one conversation**.

```
You                                       TaxPilot AI
 │
 ├─ forward a client's document to yourself
 │                                          │
 │                                          ├─ reads it
 │                                          ├─ works out the type
 │                                          ├─ finds the client
 │                                          └─ submits a proposal
 │                                                    │
 │                              ┌─────────────────────┘
 │                              ▼
 │                        CMS Approval Queue
 │                        a human approves
 │                              │
 │                              ▼
 │                        document filed
```

## What happens is decided by the document type, not the file format

Every type declares a **processing strategy**, and that is what decides whether
the document is read or asked about. A CNIC carries its client's identity on its
face whether it arrives as a photograph or a scan; a bank statement carries an
account number either way. The format is a coincidence in both cases.

| strategy | what happens | types |
| --- | --- | --- |
| `OCR_REQUIRED` | read at once, never waits | CNIC front/back, passport, licence, vehicle |
| `USER_METADATA_REQUIRED` | never opened; waits to be told whose it is | bank statement |
| `OCR_WITH_METADATA_FALLBACK` | read, and ask only if that named nobody | everything else (15 types) |

**Unknown is not a strategy.** A document the classifier could not name has not
earned a way of being handled, so the decision is deferred rather than
defaulted: an unrecognised **PDF** is asked about rather than opened, because it
is far more often a statement or a certificate than anything else, while an
unrecognised **image** is read, because a photograph nearly always shows
something that names itself. As soon as there is a real type, that type's
strategy applies.

## A document that waits


**WhatsApp shows no caption box when you forward a document.** The message
arrives with the caption field absent — not empty — so for the case this feature
exists to serve there was never a caption to read. Six real forwards on 31 July
2026 all filed as *identified by nothing* before this was understood.

Worse, a forwarded PDF that *does* carry a caption carries the one written in
whatever chat it came out of. Reading it would file this document against
somebody else's client.

So a PDF is downloaded at once and **held**, and the next plain text message in
the self-chat says whose it is:

```
 ├─ Account Statement.pdf      →  downloaded, queued, never opened
 └─ File No: TP-2026-00125     →  filed against that client
```

- **A PDF's caption is never read** — not for the client, not for the type. The
  **filename** still decides the type, which is what routes a bank statement to
  the workflow that never opens it.
- **Several PDFs queue, FIFO.** Send three, then three identifiers: the first
  text names the oldest PDF. One text names one document, never all of them.
- **Neither identifier present?** The document is still filed, with no client
  attached, in front of a person. The rule is never to guess.
- **Nobody ever names it?** After a day it goes to the approval queue anyway,
  rather than waiting for ever and then being named by an unrelated message a
  week later. `TAXPILOT_PDF_METADATA_WAIT_MINUTES` changes that.
- **The fallback** puts a document in the same queue *after* reading it, when
  the reading named nobody. The next text names it, and that second attempt
  proposes whatever it finds — asking twice for something already answered is
  how a document waits for ever.
- **`taxpilot_pending_pdfs`** on `/metrics` is the depth of that queue. A number
  that climbs and never falls means identifiers have stopped arriving.

## For an image, the caption still decides

Images are deliberately untouched by the rule above: an image can carry a
caption, its contents are what identify the client, and requiring a second
message for every photograph of a CNIC would make the working half of this
workflow worse in order to serve the broken half.

Write a client's **file number** or **CNIC** in the caption and the image is
filed against that client without being read at all. Write nothing and it goes
through the reading path above.

| Caption on an image | What happens |
| --- | --- |
| `file 1420` | Filed as a bank statement against client 1420 |
| `35202-1234567-1` | Filed as a bank statement against that CNIC's client |
| `file 1420 cnic 35202-1234567-1` | Both checked; filed only if they agree |
| `here you go`, or nothing | Read, classified, matched — the ordinary path |

A human approves either way. Nothing reaches a client's record on its own.

### Naming the document type in the caption

A caption may also say what the document *is*, and that settles it without the
file being opened:

```
Salary Slip
file 1420
```

Any registered type can be named — `Bank Statement`, `Sale Deed`, `Wealth
Statement`, `FBR Notice` — however it is punctuated (`salary slip`,
`Salary_Slip`, `SALARYSLIP` all work). Naming two types in one caption settles
nothing, because picking one would be a guess.

Naming a client but no type still means a bank statement, as it always has.

### Write "file" before a file number

This applies wherever a file number is written — an image's caption, or the text
message that names a waiting PDF.

`file 1420` works. A bare `1420` does nothing, and that is deliberate.

A CNIC announces itself by its shape — five digits, seven, one — so it needs no
label. A file number is a bare integer, and captions are full of those: "sent on
12", "3 pages", "2024 statement". A matcher that took any number would file a
document against client 12 because somebody mentioned a date.

Accepted forms: `file 1420` · `file no 1420` · `file no. 1420` ·
`file number 1420` · `file#1420` · `file: 1420` · `f-1420`. A single-letter
suffix survives, so `file 16 A` finds file 16 A.

Structured references work too: `File No: TP-2026-00125`, `File No: TP/2026/00125`.
They are kept exactly as written apart from case, because the parts of a
reference carry meaning and normalising them would be inventing a scheme rather
than reading one. The label is still required — a bare `TP-2026-00125` does
nothing, and `file number for August` finds no client rather than one called
`FOR-AUGUST`.

### If the two identifiers disagree

Both are looked up, always — the file number is preferred, but skipping the
second search would throw away the only evidence capable of showing the caption
is wrong. When they name different clients, **neither is used**: both go to the
reviewer with the disagreement stated, and a person decides.

### What can be forwarded this way

**PDF, JPG and PNG**, up to 20 MB. Anything else is refused — not because it
cannot be handled, but because a reviewer has to look at the document before
approving it, and a `.webp` or a `.tif` shows a person nothing.

A refused file produces no proposal and **no message back**. It is counted, and
the count reaches the CMS's operations console as *Forwards refused*. That is the
whole account of it in Version 1.

A file that never arrives at all — the provider refusing the media, or being
unreachable — is counted separately as *Forwards not fetched*. The two are apart
on purpose: the first means go and ask whoever sent it, the second means go and
look at Evolution.

### The document is never opened

Not OCR'd, not classified, nothing extracted — no transactions, balances, account
numbers or IBANs. A bank statement is Level 3 under ADR-0002, and the surest way
to guarantee its contents never leave the installation is never to hold them. The
file is checked to be the type it claims (its first few bytes, nothing more) and
uploaded unchanged: no conversion, no recompression, no watermark.

## Only the owner's own self-chat, and nothing else

That account also carries family, friends, other clients, and everything its
owner has ever discussed. **Almost every message the process can see is one it
must not read.**

So TaxPilot AI reads exactly one conversation: **the connected account's chat
with itself** — WhatsApp's "Message yourself" thread, where the owner is both
ends. A message is processed only when all of these hold:

| | |
| --- | --- |
| the provider says the connected account wrote it | `from_me` |
| the conversation is the owner's own | `chat == owner` |
| the sender is the owner | `sender == owner` |
| the recipient is the owner | `recipient == owner` |

### The guarantee

**Everything else is ignored and never read.** In full, and each of these has a
test naming it:

- individual conversations with other people
- client chats
- family chats
- group chats
- broadcasts
- channels
- communities
- any message where either the sender or the recipient is not the connected
  number

### There is nothing to configure

The owner's number is **discovered from the linked session** and is itself the
policy. Nothing is typed in, so nothing can be mistyped, and there is no setting
that widens it — a setting that can widen it is a setting that can be got wrong
once and read somebody's private messages forever.

`TAXPILOT_WHATSAPP_ALLOWED_NUMBERS`, `_ALLOWED_CHATS` and `_ALLOWED_SENDERS` are
**retired**. They no longer grant access to anything. Setting one produces a
warning at boot rather than silence, because an operator who set it believes a
conversation is being watched and would go looking at the provider when nothing
arrived from it.

This also removes the failure that used to hide here: a number written in a form
the matcher did not recognise matched nothing, the AI ignored every message, and
the deployment looked perfectly healthy. There is no longer anything to write in
the wrong format.

### Where the owner comes from, and where it does not

Resolved from the provider over its **authenticated API**, cached for a minute,
and refreshed so that re-linking a different account moves the inbox with it.

It is never taken from the message being judged, and never from the webhook
envelope — Evolution helpfully puts the connected account in every delivery as
`sender`, and that field is whatever the caller wrote. A forged delivery
claiming to be from another account is measured against the real owner and
refused. The envelope value is used only to label a recipient.

### Fails closed

Until the owner is known, nothing is processed. A deployment that cannot say
whose account it is attached to has no business reading any of it. Health
reports this as **degraded**, not failing: workflows already in flight still
finish.

If the provider cannot be reached, the last known owner is kept rather than
forgotten. That loses nothing in safety — an account relinked to a different
number produces a self-chat under *that* number, which the stale owner refuses
anyway — and forgetting would drop documents during a blip.

### What this means for Meta

The Cloud API only ever delivers messages **to** the business number from
somebody else. It does not hand the business its own messages back, and it has
no self-chat. So under this policy a Meta deployment accepts nothing.

That is the rule working rather than a bug, and it is why Version 1 is
Evolution-only. There is a test asserting it, so that anyone who wires Meta up
and finds an empty queue reads the reason instead of hunting for a broken
signature.

### Enforced on the server, in one place

`WebhookReceiver.receive` is the only way a message enters, and the policy is
applied there — before deduplication, before the session window, before anything
is remembered. Not in the UI, which only *describes* the rule, and describes it
from what the agent reports rather than from what the CMS release believes.

### `fromMe` cannot be used as a filter

The obvious implementation drops every message the account owner sent. It is
wrong, and it was what this code did until Phase 3: **a document you forward to
yourself is `fromMe`.** Filtering those out ignores exactly the messages the
feature exists to handle.

The AI's own outgoing replies are also `fromMe` and must not be reprocessed —
otherwise it answers itself forever. Those are told apart by **id**: every
message the sender transmits is recorded as seen, so the provider's echo is
recognised as already handled. An id we generated is the only reliable
distinction; the flag is identical for both.

### The filter runs first

Before deduplication, before the session window, before anything. A message from
a conversation this deployment may not touch leaves no trace at all — not a
remembered id, not a window entry. There is a test asserting exactly that.

Rejections are **counted, not logged by identity**. A log line naming the sender
of every ignored message would rebuild the contact list this exists to protect,
in a file that is collected, shipped and kept. A masked id appears at `DEBUG`
only, so a mistyped number is still diagnosable.

## Providers

The abstraction is intact and both are supported by design (ADR-0006).
`MessageSender` enforces **Meta's** rules for every provider — the 24-hour
session window and template approval — so nothing built against Evolution can
work in development and fail in production.

| | |
| --- | --- |
| **Evolution** | Development. Unofficial, self-hosted, no approval process — and carries a real risk of the number being banned. **Never point it at the firm's live number.** |
| **Meta Business** | Production. Requires business verification and template approval. |

## The webhook

Two paths, because the two providers authenticate very differently.

| | |
| --- | --- |
| **Meta** | `POST /webhook/whatsapp` — HMAC-SHA256 over the raw body with the app secret, checked before the body is parsed. Plus `GET` for the one-time verification handshake. |
| **Evolution** | `POST /webhook/evolution` — a shared token in `X-TaxPilot-Token`, compared in constant time. |

**Verify, filter, queue, acknowledge — in that order.** An unsigned endpoint
accepts a POST from anyone, and a forged webhook would put an attacker's
document into a reviewer's queue with a real client's name attached. The
self-chat rule runs next. Only then is the message queued.

### Evolution signs nothing, so the route leans on two things

Evolution posts plain JSON and offers only custom headers. A shared token proves
the caller knows a secret; it does not prove the body is untampered. So:

- **The agent's HTTP server binds loopback.** Evolution runs on the same
  machine, and nothing else can reach the port. This is what makes a shared
  token sufficient rather than merely better than nothing.
- **No secret means no route.** `TAXPILOT_EVOLUTION_WEBHOOK_SECRET` unset
  registers nothing at all. An absent route answers 404 and somebody notices; an
  open one quietly admits whatever finds the port.

Only `MESSAGES_UPSERT` is treated as carrying messages. Evolution sends
connection updates, presence, typing indicators and contact syncs to the same
URL, and parsing one of those as a message is how a presence update becomes a
document. Both spellings are understood — versions differ.

### The work happens afterwards, not in the request

Meta retries a webhook it considers slow, and OCR takes seconds — so doing the
work inline would earn a duplicate delivery of the document already being
processed. The handler returns 200 once the message is queued, and the daemon's
`inbox` job picks it up two seconds later.

That job downloads the media to `TAXPILOT_INCOMING_DIR` and starts the intake
workflow. The filename the provider supplies is a client's, arriving over an
unofficial API, and is used **only for its extension** — `../../etc/passwd` is a
perfectly valid WhatsApp filename. The file is named after the provider's own
message id, which is already the deduplication key.

Text in the self-chat is not work. Talking to yourself is an ordinary thing to
do, and only a document starts a workflow.

An unparseable body is answered **200**, not 400, on both routes. An error
response asks for a retry of a payload that will never parse, forever.

The inbound queue is bounded and drops the **oldest** under pressure: a retry
storm must not grow until the process dies, and the newest message is the one
somebody is waiting on.

## What is not built yet

- **No document has been carried by a real WhatsApp message yet.** The path is
  built and wired: a live Evolution instance is linked to a real number and
  posting to `/webhook/evolution`, the route authenticates, the self-chat rule
  admits the owner's own messages and refuses everything else, and the `inbox`
  job drains the queue. What has not happened is a photograph of a real document
  travelling that path into the Approval Queue — the last step needs a phone.

  The workflow behind it *has* run end to end, from a file handed over by hand
  with `scripts/submit_document.py`: OCR, classification, extraction, client
  identification and a proposal waiting for a reviewer.
- **Meta business verification and template approval** are external processes
  with real lead times, and neither has been started. Under the Version 1
  self-chat rule a Meta deployment would accept nothing anyway.
- **Nothing is ever said back.** A file refused for its type or its size, and a
  caption whose identifier matched no client, produce no reply — the first is a
  counter on the operations console and the second is a proposal a reviewer has
  to resolve. Outbound sending exists; it is not used on this path, because
  replying was not asked for and a first reply is the moment this stops being a
  one-way inbox.

The inbox rule was built and tested **before** any of this. It is the gate every
inbound message passes through, and building transport first would have meant a
period where the process could read a real account with no filter in front of it.

## Version 1 decision: the self-chat is the inbox

**Decision.** The AI processes messages only from the connected account's own
self-chat. The connected number defines that conversation, is discovered
automatically, and is the whole of the policy. No customer configuration widens
it.

**Status.** In force. Enforced server-side in `SelfChatPolicy`, applied at the
single seam every message passes through.

**Why.** A configurable allow-list makes the privacy boundary a thing somebody
has to get right, once, in a text file, for every install — and the cost of
getting it wrong is an OCR pipeline reading a private conversation. Deriving the
boundary from the linked account removes the decision rather than documenting
it. The customer's own deliberate act — putting a document in their self-chat —
becomes the only way to hand anything to the AI.

**Cost, stated plainly.** Clients cannot send documents to the AI directly;
somebody at the firm forwards them into the self-chat. That is a real
limitation and it is accepted for Version 1: it is the difference between an
inbox whose scope a customer must reason about and one whose scope is a fact.

**Guarantee.** This is a security and privacy guarantee, not a default. Widening
it is a deliberate future decision with its own consequences, not a setting.
