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.

18 min readUpdated 7 August 2026privacy, retention, gdpr, data, deletion

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

How long assistant conversations and their retrieved context are kept before deletion.

Settings → DataConversation 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:

  1. Blanks the text on every ai_support_turn row older than the cutoff. draftText, sentText and errorMessage are the text-bearing columns; citations, toolCalls and retrievedChunkIds are 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.
  2. 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.
  3. Deletes ai_support_session rows that are RESOLVED, older than the cutoff, and have no turns left. A resolved session with nothing under it carries nothing worth keeping.
  4. Blanks ai_support_admin_turn on the same clock: question and answer are emptied, sources and proposals set to NULL. The row and its costUsd stay.
  5. Deletes ai_support_admin_session rows 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_gapsampleQuestion is 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_deflectionquestion is what they had typed into the ticket form when a suggestion appeared, and the row is keyed to userId.
  • ai_support_operation and ai_support_workflow — keyed to userId, and they are audit evidence: the invariant that no COMPLETED or FAILED operation may have a null confirmedAt is 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: approvedBy is 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  tokenId

data 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_operation and propose_workflow — resend the verification email, withdraw a pending verification submission (which sets that application to REJECTED), and mark notifications read. Those tools are not offered at all unless aiSupportOperationsEnabled is 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, on PUT /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_transactions and get_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_steps and propose_guide are all still offered, and two of them are shaped by the account — propose_action pre-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

Fuse current documentation retrieved from the store with your local index.

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

Sends the questions your customers ask that nothing can answer, as word fingerprints filtered against our own documentation's vocabulary.

Settings → DataWhat 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

Offer generic answers to the gateway's shared cache, and read from it.

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:

  1. 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.
  2. 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.
  3. 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

Days to wait before closing a ticket your team answered and the customer never replied to.

Settings → DataConversation 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 REPLIED tickets — an agent or the assistant spoke last and nobody has answered since. A ticket with an unanswered question in it is OPEN and can never be swept.
  • A ticket with a RUNNING workflow is skipped. A process legitimately spans days, and for that whole time the ticket sits in REPLIED with 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.

  1. Find their tickets.

    SELECT id, subject, status FROM support_ticket WHERE userId = :userId;
  2. 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;
  3. 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);
  4. 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;
  5. Check the gap list by hand. ai_support_gap.sampleQuestion is 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;

  6. Search your administrators' own transcripts. ai_support_admin_turn stores what an administrator typed, verbatim, and the Ask about this user rail is a question about a named person. Nothing joins it to a userId, 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's costUsd is what the monthly budget cap counts, and removing it hands back allowance for an answer that was genuinely paid for.

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

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

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