Claudio Mascaro

Notes

Protecting the chat without knowing who is asking

Three ceilings, a manual ban and a single exception, fenced in writing: how this site's chat protects itself from abuse without storing who is asking.

This site's chat calls a model billed per use, sits open on the internet and stores nothing about who is asking. Asked about protection against abuse, it answered that the notes documented nothing of the kind. It was right about the notes and wrong about the chat: the ceilings had been there from day one. The question produced two things, a real hole in the design and this note.

The three ceilings

The three ceilings were born together with the chat, on purpose. Separating them would mean a moment in which an LLM endpoint with no limit sat public on the internet. And the order in the turn runs from cheapest to most expensive: the counter is read before the notes and before the model, and a barred request costs neither a read of the notes nor a token.

The first is the per-origin limit, against bursts. It is the cheapest and the weakest of the three: it keeps the origin in memory, only for the length of the window, and it is best-effort. I accepted that, because the money has a different guardian.

The second is the per-session message cap, against the endless conversation. It counts the questions in the conversation the client sent, because there is no session on the server: there is nothing that identifies the visitor to pin one to. A client that trims its own history escapes this cap, and that is said out loud rather than hidden.

What this cap really protects is the size of each turn's context, which is real cost. Against an adversarial client the per-origin limit answers; against a growing bill, the spend ceiling. Giving it teeth would require identifying who is asking, which is what the chat refuses to do.

The third is the daily spend ceiling, and it fails closed: a missing or unreadable counter shuts the chat down. The counter belongs to the model provider itself, remote and durable, and that is what makes the fail-closed real. A local counter would never fail, and a ceiling that can never fail is not fail-closed, it is decoration. The ceiling's value comes from the environment; missing or malformed is an unreadable ceiling, and it shuts the chat down. Zero is valid: it means the chat is off, with no code change. And the shutdown is visible to me, through the reason logged by id, with nothing that identifies the person asking.

The final lock is not even the site's. It is the credit limit on the key itself, at the provider, and it holds with the site down, with a bug in the counter, with a bad deploy. The site refines; the key contains.

Why this ceiling halts instead of carrying on already has a note of its own in this collection. What matters here is its place in the design: fail-open protects work, fail-closed protects money, and bursts and endless conversations cost before the day's spend shows up in the remote counter. That is why the other two exist.

A hand tool, not a wall

The three ceilings left a hole, and it was the question about abuse that exposed it: the patient abuser. Whoever stays within the burst limit and keeps going burns through the daily ceiling alone, and the chat goes dark for the real buyer. The fail-closed ceiling protects the money; nobody was protecting availability against a single origin. No abuse has happened so far; the decision was preventive, and its size respects that.

The proportionate answer is to cut the origin, not to fatten global ceilings that would punish everyone: a manual ban by origin, and every word carries weight. Manual because latency does not matter and a false positive does: against a patient abuser, the minutes of a redeploy change nothing, and banning a buyer on a heuristic would change everything. Detection that bans on its own accepts banning people by mistake, and the false positive costs exactly what the ban exists to protect. I am the one who reads the log and decides.

By origin because it is the key the platform already hands the route, and by nothing else. It is a hand tool, not a wall: a shared address punishes an innocent neighbor, a determined abuser switches addresses, and both weaknesses are accepted in writing. I refused fingerprinting and cookies: that would be the start of tracking visitors on a site that does not even have analytics, taking on more data-protection duties for marginal value.

The list lives in the environment, outside git: a banned address is personal data, and personal data does not go into the repository. Banning means editing the variable and deploying again. An entry lives until I remove it, and the expected retention is short, because removal comes when the abuse stops.

And the list fails open, unlike the spend ceiling: missing or empty, nobody is banned; a malformed entry is ignored. It is the same question as the ceiling's, answered by what sits on the other side. Behind the ceiling there is money, and doubt halts the system. Behind the list there is availability, and a broken list darkening the whole chat would hand a configuration error what the abuser wanted.

A banned origin is sent to the same exit as every degradation of this chat, the booking page, with a reason of its own. It is the cheapest verdict of all and comes before the limiter: a banned request does not count toward the limiter and costs no read at all. Hiding the ban from the banned would be theater, because whoever switches addresses finds out in a minute, and going mute with no reason would break the rule that holds for the whole chat: degradation always has a citable reason.

No identifier, with one fenced exception

What the chat persists is the text of the conversation and its language, never who wrote it. The record type has two fields, and there is nowhere to put an identifier; a test enforces that. The reason is simple arithmetic: knowing which questions a buyer asks feeds the list of what to write next; knowing who asked brings LGPD, the Brazilian data protection law, and consent into the picture, with no proportional value.

The ban had to move that boundary, and it moved it by the narrowest path that makes it crossable. For me to ban an origin, it has to exist somewhere I can read. So the origin may exist in two places only: in the banned list and in the abuse events in the log, one for a limit exceeded and one for a banned request. No record links an origin to conversation text. The conversation record still has no identifier, with the same test as before, and the original reason for storing nothing about who is asking, never linking a question to a person, still stands. And the per-session cap is still toothless: the exception opened the ban, and nothing beyond it.

The legal basis is legitimate interest, and minimal scope is part of the compliance: a short list, a single purpose, short retention. Growing any of the three means revisiting the decision, not stretching it.

The note that was missing

This note exists because the chat said it did not. When the notes cannot sustain an answer, the chat itself flags the gap, and the question goes to the log as material for what to write next; the fix for a gap is always a new note, never a filter on the reply. The question about abuse landed on that list, and what it asked for had already been decided and written down; what was missing was a rewrite for someone arriving from outside. This is the rewrite.