What is stored, what is deleted, and what leaves the install
All seventeen tables the addon writes, exactly what the 90-day sweep empties and what it deletes, what actually reaches the AI provider, and a deletion runbook for one customer.
You are the data controller. Your own compliance people will ask you three questions about this addon — what does it store, how long, and what leaves the box — and the answers are all here rather than spread across the settings screen.
Nothing on this page is a legal opinion. It is what the code does.
The tables
Seventeen tables, all prefixed ai_support_. In each of the three groups below,
the column that matters for a privacy review is the last one.
The customer conversation
| Table | What it holds | Free text? |
|---|---|---|
ai_support_session |
One row per ticket: handover state, who holds it, turn count, running cost, locale, whether the ticket was deflected, the handover brief | The brief only |
ai_support_turn |
One row per attempted answer: status, verdict, scores, tokens, cost, the drafted and sent text | Yes, indirectly |
ai_support_handover |
Append-only log of every state transition: from, to, actor, reason, note | Only an agent's note |
ai_support_feedback |
Per-answer 👍/👎 with an optional comment, by turn and user | If a comment was left |
ai_support_gap |
A question nothing could answer: normalised form, the customer's verbatim phrasing, count, best score | Yes |
ai_support_deflection |
An answer offered while a customer typed a subject line: what they had typed, the article shown, the outcome | Yes |
ai_support_operation |
An action the assistant offered and the customer confirmed: user, ticket, operation key, state, timestamps, the assistant's own one-line reason for offering it, and the result the customer was shown afterwards | The assistant's note only |
ai_support_workflow |
A multi-step process and how far through it a customer is, with the same pair of assistant-written fields | The assistant's note only |
The administrators' own assistant
| Table | What it holds | Free text? |
|---|---|---|
ai_support_admin_session |
One conversation between an administrator and their own assistant: owner, title taken from their first question, the screen it was asked from, turn count, running total. One row per admin also doubles as a spend ledger for the four one-shot surfaces that file a turn — Handbook questions, Ask about a customer, articles written from tickets, provider tests. Batch gap drafting is not one of them: it writes no turn at all and states its cost only in its own response | The title |
ai_support_admin_turn |
One admin question and its answer, both verbatim, plus the sources cited, the actions proposed, tokens and cost | Yes — and it can name a customer |
ai_support_admin_action |
Something the assistant offered to do: who it was offered to, the action key, the permission snapshotted at proposal time, the state, and approvedBy |
No |
The knowledge index and its configuration
| Table | What it holds | Free text? |
|---|---|---|
ai_support_article |
Your own knowledge base | No — your words |
ai_support_source |
One row per indexed source: kind, product slug, checksum, chunk count, weight, last indexed time, any error | No |
ai_support_chunk |
The passages retrieval searches, each with its breadcrumb, audience and citation URL | No |
ai_support_agent |
The persona | No |
ai_support_rule |
Your escalation rules | No |
ai_support_glossary |
Your own terminology | No |
Four of those deserve their own sentence.
ai_support_session carries no message text. It is the handover record —
state, custody, counts, cost — plus handoverSummary, which is the three-line
brief written from a redacted rendering of the conversation.
ai_support_turn carries the customer's question indirectly. Not as a field:
through draftText (what the model wrote) and sentText (what was actually
sent), which quote and paraphrase what was asked. It also carries costUsd,
token counts, retrievedChunkIds, citations, toolCalls, retrievalScore,
groundedness, promptHash, errorCode and errorMessage. This one table is
the cost ledger, the audit trail, the gap-report input and the evaluation
baseline at once, which is why it is the one the sweep is aimed at.
ai_support_admin_turn is the newest place customer data can land, and it is
not obvious. It stores what an administrator typed, word for word — and the
Ask about this user rail on the user detail page is a question about a named
customer, so "why is this user's withdrawal stuck" is filed with whatever
identifier the administrator pasted into it. It exists because the admin console,
the Handbook and that rail were spending money and recording nothing: the answer
lived in the browser until somebody navigated away, and the spend was invisible to
the budget cap that was supposed to be limiting it. Both halves are fixed by this
table, and the monthly cap now sums costUsd across it as well as the customer
one.
ai_support_admin_action is the approval trail, and approvedBy is the point
of it. It is written from the approving request's own authenticated session,
never from the proposal, so a COMPLETED row names the person answerable for the
action. The invariant is checkable as a query and holds forever: no row can be
COMPLETED or FAILED with a null approvedBy.
The conversation itself lives in support_ticket.messages, which belongs to core
support and is not this addon's to delete.
The retention sweep
Settings → Data → Conversation lifecycle. Range 1–3,650 days. The job is
aiSupportRetentionSweep, listed in the cron console as AI Support Retention
Sweep and running every 24 hours.
It runs in five statements, and the shape of them is empty the words on your schedule, keep the money until it can no longer matter:
- Blanks the text on every
ai_support_turnrow older than the cutoff.draftText,sentTextanderrorMessageare the text-bearing columns;citations,toolCallsandretrievedChunkIdsare JSON that can quote retrieved passages and tool arguments. All six are set to NULL. Not just the closed tickets — every row past the window, open conversation or not. - Then deletes the row itself, but only once it is past both bounds: older than your cutoff, and older than the first of the current month in UTC.
- Deletes
ai_support_sessionrows that areRESOLVED, older than the cutoff, and have no turns left. A resolved session with nothing under it carries nothing worth keeping. - Blanks
ai_support_admin_turnon the same clock:questionandanswerare emptied,sourcesandproposalsset to NULL. The row and itscostUsdstay. - Deletes
ai_support_admin_sessionrows whose last message is older than the cutoff. There is no foreign key from the turns to the session, so this removes the history entry while leaving the emptied turns and their cost behind — which is correct, because a thread whose every turn is blank is a row nobody can use, and its spend still has to count.
The monthly spend cap is SUM(costUsd) over both turn tables since the first of
the current month. Deleting a turn removes a dollar from that sum, so a sweep
that hard-deleted on the operator's schedule refunded spend against the
ceiling.
That was not theoretical. On seven-day retention with a $100 monthly cap the ceiling was unreachable: a week's worth of turns was deleted out from under the sum every night, so the number the gate compares never grew. The guard that exists to stop a runaway bill was quietly uncapped on exactly the installs most careful about their data.
So step 2 waits. On the default 90 days the cutoff is already well outside the budget window and every emptied row is deleted in the same run — nothing changes. On any retention shorter than a month the row stays, with no text in it, until the month it was billed in is over. What survives is a cost, a token count and a timestamp.
The session row is the handover record and carries no message text, and the Live Inbox joins against it for conversations that are still open. Deleting sessions alongside their turns would break that join and destroy the custody record of live work.
So after a sweep you can still see that a conversation was escalated, to whom, when, and what it cost — you just cannot see what was said. That is the intended outcome, and it is the shape a retention policy should have.
ai_support_handover and ai_support_feedback hang off the session and the turn
by foreign key with a cascade delete, so they go when their parent does — they
have no age-based sweep of their own. Note what that means on a short retention:
a feedback comment survives as long as its turn does, which is now longer than the
turn's own text.
Nothing ages out of these, and two of them hold text a customer typed:
ai_support_gap—sampleQuestionis the customer's own verbatim wording, kept so you can read what was actually asked. There is no user id on the row, but free text a customer typed can contain anything they typed.ai_support_deflection—questionis what they had typed into the ticket form when a suggestion appeared, and the row is keyed touserId.ai_support_operationandai_support_workflow— keyed touserId, and they are audit evidence: the invariant that noCOMPLETEDorFAILEDoperation may have a nullconfirmedAtis what proves nothing ran without a customer's click. Deleting them destroys that proof.ai_support_admin_action— the approval trail for the admin assistant, and audit evidence for the same reason:approvedByis what names the person who authorised a platform action. It holds no customer data.
Include the first two in any deletion request. See the runbook below.
What reaches the provider, and what is stripped on the way
Answering a question means sending it to a model. Be precise with your compliance people about which parts of that are filtered and which are not, because the two are not the same.
Sent as written:
- The customer's question. Verbatim. With multilingual answering on it crosses twice: once on its own to a cheap model that renders it in English, because retrieval and the policy gate both need English, and then again inside the answering call. The answering model is given the customer's own words and told which language to reply in; the English rendering is not sent with them.
- Up to the last 20 messages of the ticket, both sides, as the conversation context. This is the customer's own prose and anything your agents typed into the thread.
- The retrieved passages. These are your documentation, your articles, your FAQ rows and your configured fees — your words and your numbers, not customer data.
- Your persona, your glossary and your site name, which are the top of every prompt.
All of it is escaped first, but understand what that is for: escaping neutralises
markup like </context> so a customer who types one cannot break out of the data
block and have the rest of their message read as instructions. It is a
prompt-injection defence. It removes nothing and it is not redaction.
If a customer types their wallet address, their transaction hash or their phone number into a support ticket, that text goes to the AI provider as part of the question and as part of the conversation history.
The redactor described below runs on account-tool results, on the handover brief, and on the two paths that turn a ticket into a knowledge article. It does not run on the customer's own message, and it could not: the point of that message is to be answered, and an answer to "why hasn't 0xabc… arrived" has to be able to see what was asked.
What follows from that is a procurement question, not a settings one. Read your provider's data-retention terms — see Provider — and if support conversations on this platform routinely carry material you cannot send to a third party at all, the assistant is the wrong tool for those tickets, not a tool to be configured differently.
Filtered before it is sent: everything the assistant reads out of your database about the customer. Two mechanisms, because that data arrives in two shapes.
A field denylist, applied to every account-tool result before it can enter a prompt. It is a second net behind per-tool allowlists, because a hand-maintained allowlist drifts as models gain columns:
password secret twoFactorSecret key apiKey privateKey seed mnemonic
data checks documentVerifications metadata
address walletAddress trxId txHashPending memo
ip userAgent pushTokens webPushSubscriptions tokenIddata is the one doing the heaviest lifting: it is the column name behind
walletData.data, ecosystemMasterWallet.data and kycApplication.data, so
wallet blobs and KYC document payloads are removed by name.
A free-text redactor, because a denylist matches keys and some of this is
inside a sentence. transaction.description is written by the withdrawal, the
ecosystem wallet and the Monero blockchain code with the destination address —
and sometimes the transaction hash — interpolated directly into prose. A
denylist listing walletAddress and trxId cannot see an address that is simply
part of an English sentence.
So every transaction description passes through a redactor before it can enter a
prompt. It replaces rather than deletes: Withdrawal to [address] survives as a
usable sentence, Withdrawal to does not, and the model will invent the missing
half. It recognises email addresses, Ethereum and Bitcoin and Tron and Monero and
Solana addresses, transaction hashes, UUIDs, seed phrases, card-like digit runs,
phone numbers and IP addresses — longest pattern first, so a 64-character hash is
not eaten as a 40-character address with a tail.
That redactor runs in exactly four places, and it is worth knowing all four because they are the four places a conversation is shown to a model:
| Where | What it redacts |
|---|---|
get_recent_transactions |
Each transaction's free-text description |
| The handover brief | The conversation, before the three-line summary is written from it |
| Teach from this ticket | The whole ticket, before the drafting model sees it |
| The nightly gap harvester | The customer's question and the agent's reply, before either is filed as a draft article |
Nothing else. In particular, not the live answer path — see the warning above.
Three further structural facts about the tools:
- No tool schema accepts a user id. Every one reads the authenticated session's own id. A tool that took one would be a lateral-read primitive one prompt injection away.
- No tool performs a write itself. The assistant cannot move funds, change
settings, or place or cancel an order. It can offer three account actions
through
propose_operationandpropose_workflow— resend the verification email, withdraw a pending verification submission (which sets that application toREJECTED), and mark notifications read. Those tools are not offered at all unlessaiSupportOperationsEnabledis on, which is Super-Admin and off by default, and even then the model only proposes: the write runs when the customer presses the button themselves, under their own session, onPUT /api/ai/support/operation/{id}. Every other tool is read-only. - Account reads can be switched off entirely.
aiSupportAccountToolsEnabled(Super-Admin only, on by default) is docs-only mode: with it off the three reading tools —get_account_summary,get_recent_transactionsandget_my_activity— are withheld from the model and refused by the executor, so no account row, balance or transaction leaves the install. The rest of the executor still runs:request_human,propose_action,propose_stepsandpropose_guideare all still offered, and two of them are shaped by the account —propose_actionpre-flights the page against the caller's own verification level, and the guide list is filtered the same way — so "this customer cannot enter that page yet" still reaches the model. It is not a mode in which nothing about the customer is known.
What can leave the install
Four channels, and only the first is unavoidable.
| Channel | Ships | What crosses |
|---|---|---|
| The AI provider | On — it is the product | The question and the recent conversation as written; the retrieved passages; and, when account tools are on, an account summary that has been denylisted and redacted |
aiSupportRemoteDocsEnabled |
On | Your search query, nothing else |
aiSupportShareQuestionsEnabled |
Off | Word fingerprints of unanswered questions, and counts |
aiSupportAnswerCacheEnabled |
Off | Offers a generic answer to a shared cache |
The first row covers your own staff as well. The admin console, the Handbook and
the Ask about this user rail all call the same provider, sending the
administrator's question and the operator-corpus passages that answer it. That
rail is the one place the assistant reads an account other than the caller's own,
and it sends a summary of a named customer — through the same field denylist and
the same redactor, so passwords, wallet addresses and transaction hashes do not
cross even though a member of staff with view.user can see some of them on the
page they asked from. Every one of those questions is written to the admin audit
trail, because who asked a model about which customer is exactly the access an
operator needs to be able to review later.
Store-hosted documentation retrieval
What is sent is a JSON body of { query, topK, audience } to /v1/retrieve,
signed with your gateway credentials. The query is the retrieval query — for
an English customer that is their question as typed; with multilingual answering
on, it is the English rendering of it. No user id, no ticket id, no account data,
no answer text.
It needs AI_SUPPORT_GATEWAY_TOKEN and AI_SUPPORT_GATEWAY_SECRET in .env;
without them the call is skipped entirely. Every failure path returns nothing and
the answer proceeds on local passages alone, with a 2.5-second timeout.
Sharing unanswered questions
Settings → Data → What leaves this install. Off by default, because this
sends data about your customers to your supplier and that is a decision you make
rather than one an upgrade makes for you. The job is aiSupportShareQuestions
and it runs weekly.
What crosses is a fingerprint and a count. The fingerprint is the question's terms intersected with the vendor's own shipped documentation vocabulary, and that intersection is the entire privacy design:
- A hash would be perfectly private and perfectly useless — nobody can write a
documentation page for
a3f91c…. - The question's content words would be useful and would carry whatever the customer typed: an email address, a wallet address, an amount, a name.
- A term that appears in the vendor's own shipped documentation is, by construction, their word. It is identical on every install, they wrote it, and it cannot be a customer's wallet address or your brand name.
So the filter keeps exactly the part that is about the product and drops everything else, without having to enumerate what "sensitive" means. Your own articles are excluded from that vocabulary on purpose — your words would identify you.
Four further bounds: at most 12 terms per fingerprint, at least 3 (a single word is not a question), only gaps asked by two or more customers, at most 200 rows. An install with the documentation packs disabled has an empty vocabulary and sends nothing — it does not fall back to sending everything.
The cross-tenant answer cache
For a data posture, this is the one to understand properly. "Offering an answer to a cross-tenant cache" means: an answer generated for your customer may be served to another operator's customer, and vice versa.
Three conditions must all hold before a turn is even marked shareable:
- Nothing operator-authored in the context. Not one of your articles, not your FAQ, not a platform-fact passage. Only the vendor's shipped documentation may ground a shared answer.
- Not a policy question. Fees, limits, review times, countries and refunds are commercial terms. The gateway re-derives this check independently, and this side does not rely on that.
- No tools ran. Not merely no account tools: an offered action resolves against the caller's own verification state, so the same question can correctly produce a button for one customer and a "verify first" explanation for another. An answer shaped by that is not a shared answer.
The economics are why it exists — a cache hit bills the gateway's flat answer floor instead of a generated answer — but it ships off, and there is a verification note attached: during testing, two consecutive repeats of a shareable question produced no reply and recorded no turn at all. A customer receiving nothing is worse than a customer receiving an expensive answer.
aiSupportAnswerCacheEnabled is a real key, accepted by the addon's settings
route, and it is not Super-Admin gated. But no field renders it on
/admin/ai/support/settings, so in practice it is off unless somebody writes it
through the API.
Treat "off" as the current state of every install that has not deliberately gone around the console.
Closing a ticket is a one-way door
Settings → Data → Conversation lifecycle. Range 0–90; zero is off and off is the default.
The customer's own reply route returns 403 — "Cannot reply to a closed ticket". Replying does not reopen it. The customer's only route back is to open a new ticket.
So an auto-close is you ending a conversation on the customer's behalf, and doing it silently would be indistinguishable from the product losing their ticket.
Because of that, the job posts an explanation before it changes the status — appending to a closed ticket is itself refused, so writing the status first would leave the customer with a ticket that silently became unusable and no explanation in it.
The rest of the job's behaviour follows from supportTicket.status being a
whose-turn axis:
- It only ever touches
REPLIEDtickets — an agent or the assistant spoke last and nobody has answered since. A ticket with an unanswered question in it isOPENand can never be swept. - A ticket with a
RUNNINGworkflow is skipped. A process legitimately spans days, and for that whole time the ticket sits inREPLIEDwith nothing from the customer — exactly the shape this sweep looks for. Closing it would remove the only button that continues the process. - 200 tickets per run, so switching it on for the first time does not close forty thousand tickets and write forty thousand notifications in one tick.
Closing a ticket through a close route — the customer's own close, ending a
live chat, or an admin writing the status — resolves its AI session, which is
what eventually makes the session eligible for the retention sweep. This job
does not. It writes CLOSED onto the ticket directly rather than going through
the core→addon bridge, so a ticket auto-closed here leaves its session in
whatever state it was in — normally AWAITING_USER. The turns under it still age
out on schedule; the session row, never reaching RESOLVED, is never swept.
A separate hourly job, aiSupportExpireStale, retires offered actions and
abandoned processes. It runs whether or not auto-close is on, because an
abandoned RUNNING workflow otherwise exempts its ticket from auto-close for
ever. It never undoes anything that already ran.
A deletion runbook for one customer
There is no one-click erasure. Take a backup first, and run these against your own database.
ai_support_operation rows are the proof that no action ran without the
customer's own confirmation. Deleting them is sometimes required and is never
free — record what you deleted and why.
-
Find their tickets.
SELECT id, subject, status FROM support_ticket WHERE userId = :userId; -
See what the addon holds for them, before deleting anything.
SELECT COUNT(*) FROM ai_support_session WHERE ticketId IN (:ticketIds); SELECT COUNT(*) FROM ai_support_turn WHERE ticketId IN (:ticketIds); SELECT COUNT(*) FROM ai_support_deflection WHERE userId = :userId; SELECT COUNT(*) FROM ai_support_operation WHERE userId = :userId; SELECT COUNT(*) FROM ai_support_workflow WHERE userId = :userId; SELECT COUNT(*) FROM ai_support_feedback WHERE userId = :userId; -
Delete the conversation trail. The models declare cascades from ticket to session and from session to turns and handovers, but do not rely on them for a compliance action you have to be able to prove — delete children first and check the counts.
DELETE h FROM ai_support_handover h JOIN ai_support_session s ON s.id = h.sessionId WHERE s.ticketId IN (:ticketIds); DELETE FROM ai_support_turn WHERE ticketId IN (:ticketIds); DELETE FROM ai_support_session WHERE ticketId IN (:ticketIds); -
Delete the rows keyed to the user directly.
DELETE FROM ai_support_deflection WHERE userId = :userId; DELETE FROM ai_support_feedback WHERE userId = :userId; -- audit evidence; see the warning above DELETE FROM ai_support_operation WHERE userId = :userId; DELETE FROM ai_support_workflow WHERE userId = :userId; -
Check the gap list by hand.
ai_support_gap.sampleQuestionis verbatim customer wording with no user id on the row, so it cannot be joined — it has to be searched.SELECT id, sampleQuestion, count FROM ai_support_gap WHERE sampleQuestion LIKE '%<a distinctive term from their question>%';Clear the phrasing rather than the row if you want to keep the demand signal:
UPDATE ai_support_gap SET sampleQuestion = NULL WHERE id = :id; -
Search your administrators' own transcripts.
ai_support_admin_turnstores what an administrator typed, verbatim, and the Ask about this user rail is a question about a named person. Nothing joins it to auserId, so like the gap list it has to be searched.SELECT id, adminId, LEFT(question, 200), createdAt FROM ai_support_admin_turn WHERE question LIKE '%<their email, id or a distinctive term>%';Empty the text rather than deleting the row —
UPDATE ai_support_admin_turn SET question = '', answer = '' WHERE id = :id;— because the row'scostUsdis what the monthly budget cap counts, and removing it hands back allowance for an answer that was genuinely paid for. -
Check for articles drafted from their ticket.
SELECT id, question, status, generatedBy FROM ai_support_article WHERE sourceTicketId IN (:ticketIds);The article is the general pattern, not the transcript, and the conversation was redacted before it was written — but read it. If anything specific survived, this is where it is.
-
Re-index from Sources (
/admin/ai/support/knowledge) if you deleted or edited any published article, so the passages go with it. A deleted article that stays in the index keeps being quoted. -
The ticket itself belongs to core support, not to this addon. Handle it under whatever policy you apply to
support_ticket.
Related: Provider for what is sent to the AI service and why the key is not in the database, Settings for the controls named here, Permissions for who can change them, and How the index is built for what is in the corpus.