# Port: `edguard` → `extensions/guardian`

2026-08-11

The tenth port, and the largest. AI content moderation with a staff triage
queue, plus a reporting workflow for illegal material that is built to keep
moderators away from it.

Two questions were blocking it, and Ernest answered both:

> *"on edguard allow choice of ai model and yes make it like the ips version."*
> *"Everything needs to be just like the IPS version."*

So the operator picks provider **and** model, and every part comes across —
including the half that is a legal workflow rather than a feature.

## What it is

Content is scanned after it is posted. Each detector returns a **verdict**: a
severity from 0 to 100, some labels, and one sentence a moderator can read. The
worst verdict decides. Above the flag threshold it goes in the queue; above the
hide threshold the post is hidden first and reviewed after.

Four detectors, and 🚨 **the order is a correctness requirement, not a
performance one**:

1. **Arachnid Shield** — visual matching against a vetted hash set of known
   illegal material, run by the Canadian Centre for Child Protection, free to
   platforms.
2. **A generic hash endpoint** — for a service a site has arranged itself.
3. **Text** — spam, scams, harassment, and the community's own rules.
4. **Images** — adult content, graphic violence, advertising.

The hash checks run **first** because if content matches known illegal material
it must never be transmitted to a general-purpose model endpoint. A locked match
ends the scan immediately: nothing else is called, nothing else sees it.

## 🚨 Nothing scans in a visitor's request

The original queued the work through Invision's task system. Convoro has a
standing rule and a live outage behind it, and this is the worst possible case
for breaking it — an outbound model call, on somebody's post, on every post.

So a post being created queues a job and nothing else. The job gathers the
subject once, runs the detectors, and writes the verdict. A site with no cron
gets no scanning, and the admin screen says so in those words rather than
looking like it is working.

## What "locked" means, and why the screen is empty

An item matched a known-illegal hash set. Then:

- **The post is hidden**, never deleted. A false positive has to be reversible,
  and genuinely illegal material has to be preserved rather than destroyed.
- **Its text is not stored** and its images are never rendered. The queue row
  for a locked item carries no excerpt, no thumbnail and no link through to the
  post. 🚨 That is the feature. Nobody on the team has to look at it to deal
  with it, and "the tab you want to stay empty" is what the empty state says.
- **A preservation record opens at detection time**, not when somebody gets
  round to filing. The identifying details are perishable — an account can be
  deleted and an IP record rotated away in the days before a report is filed —
  and US providers must preserve the material and associated data for 90 days
  from reporting (18 U.S.C. §2258A(h)). Nothing in this extension prunes a row
  while that window is open.

## Reporting

The destination is the operator's, and the credentials are the operator's.
Guardian files **under the site's own account**, never its own — NCMEC issues
credentials to registered Electronic Service Providers, and a report has to come
from the provider that holds the account.

Reporting duties differ by country, so the destination is a setting, and a site
whose hotline is not listed gets INHOPE, which routes to the national one. The
NCMEC integration has a test environment and 🚨 **says on the screen which one
it is pointed at** — "nothing filed from here is a real report" against "reports
filed from here are real reports". Getting that wrong in either direction is
serious, so it is a sentence rather than a checkbox label.

Where an API cannot be spoken, the workflow is still supported: the screen links
to the destination's own form and takes the reference number afterwards, so the
item ends up with a complete record either way.

## Money

Every metered call is recorded against a monthly spend limit. When the limit is
reached **the paid checks stop and the hash matching keeps running**, because
that is the one nobody would choose to switch off.

Three other things keep the bill down, and they are all parity:

- **Established members are skipped** above a configurable post count. Guests
  and new accounts are always checked. This is the single biggest saving.
- **Trusted groups are skipped**, and staff always are.
- **Images get their own model setting**, because vision is the expensive half
  and a site may want text checked by something better than its images.

And if the AI Assistant extension is installed, Guardian can borrow its
provider, key and ledger, so a site manages one key and one budget rather than
two.

## 🚨 Two prompt-level decisions worth keeping

**The post is content, never a directive.** The classifier is told in the system
prompt that text inside the post is being assessed and is not an instruction to
it. A moderation classifier that can be talked out of its job by the thing it is
moderating is not a moderation classifier.

**A refusal is a signal, not a failure.** When a model declines to assess a
post, that is recorded as inconclusive with a *high* severity and sent to a
human — because on a moderation product, not knowing is not the same as being
clean, and a refusal usually means the content is extreme.

The same principle covers every inconclusive verdict: a transport failure or an
unreadable response surfaces rather than sinking quietly to the bottom of the
queue.

## What Convoro makes different

Three, and each is a constraint rather than a preference:

- **Hiding is `is_hidden` on the post**, through the moderation module, rather
  than Invision's `Hideable` trait. Same outcome: hidden, reversible, and in the
  moderation log.
- **There is no `Task::queue`.** Convoro's queue takes a handler key and a
  payload, so the scan job carries the post id and re-reads it rather than
  carrying a serialised object.
- **Attachments are rows in `attachments`** with a path on disk, so the subject
  gathers bytes from there rather than from a file storage abstraction.

## Not carried across

`sources/Usage/Usage.php` — the licence-tier gate every one of these apps
carries. Nine ports have now dropped it for the same reason: find what it gated
and decide, rather than porting the gate. Here it gated nothing that matters to
a Convoro site.

---

## Addendum, same day: borrowing the AI Assistant's key

Ernest asked for Guardian to use the Assistant's key rather than its own, and
the switch to do it was already in the settings. It could never have worked. It
looked for `assistant_api_key` and summed a `spend` column; the Assistant stores
`assistant_anthropic_key` and `cost_cents`. Both were guesses and neither was
checked against the Assistant itself.

🚨 **Both failed silently, which is the part worth keeping.** The borrow finds
nothing, Guardian reports no key configured, and the operator is left looking at
a switch that does nothing — no error anywhere. Reading `cost_cents` as dollars
is the mirror image: a hundredfold overstatement of spend that stops every paid
check on a site that had spent four dollars, equally quietly.

Borrowed names are now constants with a test asserting them as literals. They
belong to another extension, so the failure has to happen here, on a build, and
not on somebody's site.

**Sharing defaults to on.** If the Assistant is installed it already holds a
key, a provider and a budget; asking an operator to paste the same key twice and
then keep two budgets in step is asking them to do bookkeeping the site can do
itself. `sharingKey()` requires a real key rather than just the switch, so it is
inert when the Assistant is absent.

**And the manifest floor was wrong.** Guardian and Summary both asked for
`^1.3.7` while using nothing newer than 1.3.4, which blocked their own install
for no reason. A floor is a promise about what the code needs, not a wish for
the newest core — the version to name is the one whose seams the extension
actually touches.
