← Ambasdr Docs
Safety · User-Generated Content

Reporting and Takedown

How somebody tells us a profile or an AI reply is harmful, where that report goes, and what an operator can actually do about it. Written for whoever is on the other end of one of these at 2am, and for the next person to change any part of the path.

The parties, before any of the mechanics

Three kinds of person touch a public profile, and the whole design follows from what each of them has.

Why this exists

Three store policies require a reporting path with a human behind it, and one requires an AI disclosure:

Filing a report

One endpoint, unauthenticated: POST /v1/public/pages/{slug}/reports. It is registered inside the existing /v1/public group, so the optional-JWT middleware applies — a signed-in reporter is recorded, an anonymous one is not turned away. Requiring a sign-up before somebody could report child-safety content would be a reporting path in name only.

FieldWhere it comes fromWhy
reasonthe reporter's choice, requiredOne of nine categories, each traceable to the policy that asks for it. Validated against Go constants in internal/model/reports.go — an unrecognised value is a 400, never quietly refiled as other, because a child-safety report misfiled as something else is the outcome the Child Safety Standards exist to prevent.
reported_textthe client, optionalThe assistant message being reported, sent verbatim. Stored conversations keep their messages in a JSON array with no per-message identifier, so there is nothing a request could point at. Sending the text means an operator sees exactly what the reporter saw, and the record survives the conversation later being deleted. It is untrusted input: stored as data, escaped when rendered, never placed in a model prompt.
detailsthe reporter, optionalTheir own words. Truncated rather than refused — losing the tail of a long report is better than losing the report — and capped in runes, not bytes, so text in any language is not cut mid-character.
thread_idthe client, optionalSo an operator can read around the reply while the conversation exists. A thread belonging to a different page is dropped rather than honoured: attaching it would put one member's transcript in front of an operator reviewing another.

The endpoint answers 404 for a handle that does not exist, one that is unpublished, and one whose owner is suspended — the same answer in all three cases, so it cannot be used to discover which handles are taken. Its request body is withheld from the request log (bodyLogPolicies in handler/middleware_log_policy.go): the free text is somebody's account of being harassed, on an unauthenticated route, and logged it would sit outside the database's retention rules and outside the user purge.

Where a report goes

A row in reports (migration 000064), and a Slack message through the same notifier the cost, signup-health and voice-health monitors use. The notification carries the category, the handle and the report id — never the reporter's or the assistant's words, because Slack retention is outside this system and an operator has to open the panel to act anyway. Child-safety reports are raised at critical severity; everything else at warning. A notifier failure is logged and never fails the request: a Slack outage must not lose a report.

Operators work them at /panel/reports, with an open-report count on the panel home. Resolving one records who decided and why, and is not last-writer-wins: the update requires the report to still be open, so a second operator is told rather than silently overwriting the first one's note.

What an operator can do

Before this, the answer was two things, neither proportionate: suspend photographs in chat (pages.chat_images_suspended), which leaves the profile, the chat and every attached file fully public; or delete the whole account, which destroys the knowledge graph, the object storage and the Auth0 login, irreversibly. The middle option is now a member suspension.

users.suspended_at (migration 000065). When it is set, every public surface answers 404:

The member keeps their account, their data and access to their dashboard, and nothing is deleted — lifting the suspension restores everything at once. One consequence is worth knowing before you apply one: the dashboard renders the member's own avatar and file previews through those same public proxy URLs, because there is no authenticated byte-serving route for either. So while a member is suspended their own photo and file previews do not render for them. The API stops advertising those URLs rather than handing out links that 404 (avatarURLFor, DocumentHandler.proxyServableFor), which is the same thing the older photo lever already did.

Why the flag is on the user and not on the page

This is the part most likely to be "simplified" later, so the reason is recorded. A page-scoped flag is escapable by the owner in three requests that all exist today:

  1. DELETE /v1/pages/{page_id} soft-deletes the page, taking the suspended row — and the flag on it — out of the live set.
  2. The handle is free again: idx_pages_slug is a partial unique index, WHERE deleted_at IS NULL.
  3. Re-onboarding passes, because its double-submission guard counts non-deleted pages only — and onboarding inserts its page already published.

The same content is then live at the same URL with no flag on it. No page-scoped column can close that, because after step 1 there is no suspended page row left to consult. The member is what is being suspended, so the member is where the flag belongs.

One predicate, not a second test at every site

repository.PageAvailableToPublic answers "may the public have anything from this page?" — not soft-deleted and published and the owner not suspended. Every one of the five service functions that previously read pages.is_published for public access calls it. Adding a second boolean checked by hand at five places is how five becomes four, and the one that is missed keeps serving exactly the content a takedown was for.

Deleting a member, and what survives

Reports about a member's page go when the page goes — reports.page_id cascades, so the user purge needed no change for that half. A report a member filed about somebody else's page is different: it is a moderation record about another person's content, and letting an account deletion erase it would hand anyone a way to withdraw reports against a profile. So the row survives, and the purge clears details — the one field the reporter wrote — while the foreign key nulls the link to them. What remains is which page, which category, the quoted reply and when.

What is labelled as AI-generated, and where

On the web, a standing note sits above the chat on the public profile — on both the phone card and the desktop stage. It is deliberately not attached to the message list: the chat panel returns early from four branches and only two render messages, and the empty state is what every visitor sees first.

This note is a standing one, shown every visit, and it is what the policy's "indicate clearly" asks for. There was previously also a consent gate that blocked a visitor's first typed message until they acknowledged it; that was removed on 7 October 2026, because what a visitor is owed about processing is notice rather than permission, and because a gate answered once and remembered per browser showed a returning visitor nothing at all. Typing is still ungated, and that is deliberate.

It is no longer the only thing a visitor can be asked, and this paragraph said it was until 11 October 2026. Since 8 October the microphone has its own gate: tapping it opens a dialog saying that speech is turned into text, that the text goes into this page's conversations where the page owner can read it, and that Ambasdr stores the text rather than an audio recording. Nothing connects until the affirmative button is pressed; Cancel, Escape and the backdrop all refuse. It is asked once per browser tab, per page.

That is not the deleted gate returning, and the difference is the whole reason both can be true at once. The old gate asked a visitor to consent to data processing, under Apple 5.1.2(i) and on the assumption that the lawful basis was the visitor's consent — which the published policy never rested on, so it collected a consent nothing hung on. The voice dialog answers a different law: US state wiretap statutes, where an all-party-consent state requires every participant to agree before a conversation may be recorded. GDPR notice does not answer that, and neither does the standing label above, which says a machine is answering and that a named human reads the conversation — not that a voice is being captured. Typing is not recording, so the acknowledgement applies to the microphone and to nothing else.

In the mobile app the label is on three surfaces: the onboarding teach-chat, the dashboard refine chat, and conversation transcripts. There is no visitor chat in the app to label — it is member-only, and a member who wants to see their live page as a visitor does gets the real web page in a WebView, which carries the web label.

What a human still has to do