Surface 02 · CanaryRAG

Inject a trap fact

A trap fact is a plausible, fictional detail woven into your document: a budget figure, a codename, a date. Humans skim past it, but a model that ingested your content reproduces it, even paraphrased. When it surfaces in a completion, that's proof of ingestion.

Every call below sends an API key in the X-API-Key header and your user ID as user_id. Create the key at Settings → API keys, which shows your user ID too. The key is shown once: read it into CV_KEY with the line below, which keeps it out of your shell history. The CanaryRAG console ends with these calls written out with your ID in them, in curl and in Python.

bash
# A key from Settings > API keys. It is shown once, when you create it.
# Run this line on its own, in bash or zsh, then paste the key and press
# Enter: read -s keeps it off the screen and out of your shell history,
# and takes the next line it is given as the key. In CI, set CV_KEY in
# the environment instead.
printf 'CanaryVaults API key: ' && read -rs CV_KEY && export CV_KEY && echo
bash
# weave trap facts into a document you distribute: policy.txt, in UTF-8
# (jq turns any text into JSON; --rawfile needs jq 1.6 or later)
jq -n --rawfile text policy.txt \
  '{user_id: "<your-user-uuid>", document_name: "policy.txt", document_text: $text}' |
curl -sS -X POST https://api.canaryvaults.com/canaryrag/inject \
  -H "X-API-Key: $CV_KEY" -H "Content-Type: application/json" \
  --data-binary @-
# → injected_document: your document with the trap facts added (use it in
#   place of the original wherever the document is shared or indexed), and
#   canary_facts: the three facts, each under the CNRY- id an alert names

Each trap fact is added to a sentence your document already has. Every character your document had stays as it was: the words added for the facts are all that is new, so a diff against your copy shows them, in three sentences, and nothing else.

Feed the monitor output from any assistant you can query that should not have your document; when a trap fact comes back above a confidence of 75, the hit is recorded and you are alerted over Telegram and email with the canary id, the matched fact, its confidence, the document it was planted in and where the answer was seen.

A trigger is not sealed, but it is not unevidenced either. It is an ordinary row holding the confidence, the matched fact and a 500-character snippet of the output, and it carries a content_hash in the unchained format CanaryAudit records used before chaining: the SHA-256 of seven values joined with |. The read path returns canary_fact_id beside it precisely so you can recompute the digest from the row and check it has not changed, as below. When anchoring succeeds the row also carries a blockchain_tx_hash; when it does not, that field is empty rather than invented.

What a trigger does not get is a place in the hash-sealed evidence log. CanaryVaults tripwires, CanaryHoneypot hits, CanaryAudit records and CanaryShield blocks go there; a trap-fact trigger lives in its own table, so the public verifier has nothing to resolve for it. You verify it by recomputing the hash yourself, not by asking the verifier. Triggers written before the hash column existed carry an empty content_hash, which means absent, never failed. If you need a leak recorded in the sealed log itself, write one through CanaryAudit.

bash
# triggers.json holds the answer of GET /canaryrag/triggers/<your-user-uuid>
python3 - <<'PY'
import hashlib, json
user_id = "<your-user-uuid>"
t = json.load(open("triggers.json", encoding="utf-8"))["triggers"][0]
if not t["content_hash"]:
    raise SystemExit("no content_hash: this trigger was stored before hashes were")
snippet = hashlib.sha256(t["ai_output_snippet"].encode()).hexdigest()
parts = [user_id, "canaryrag", "canaryrag.leak_trigger", t["source_url"],
         "fact=" + t["canary_fact_id"] + ";snippet_sha256=" + snippet,
         "confidence=" + str(t["confidence"]), t["timestamp"]]
print(hashlib.sha256("|".join(parts).encode()).hexdigest() == t["content_hash"])
PY
bash
# answer.txt holds the reply to check
jq -n --rawfile answer answer.txt \
  '{user_id: "<your-user-uuid>", ai_output_text: $answer, source_url: "https://chat.example.com/thread/42"}' |
curl -sS -X POST https://api.canaryvaults.com/canaryrag/monitor \
  -H "X-API-Key: $CV_KEY" -H "Content-Type: application/json" \
  --data-binary @-
# → triggered, and matched_facts: each planted fact the reply repeated above
#   the line, with its confidence and the document it was planted in.
#   source_url is optional: where the reply was seen, kept with the trigger.

Both lists the console shows come from the same API: your documents, each with the facts planted in it and the copy that carries them, and your recorded triggers, newest first.

bash
curl "https://api.canaryvaults.com/canaryrag/documents/<your-user-uuid>" \
  -H "X-API-Key: $CV_KEY"

curl "https://api.canaryvaults.com/canaryrag/triggers/<your-user-uuid>" \
  -H "X-API-Key: $CV_KEY"

A document you no longer want watched can be deleted, with its planted facts and its injected copy. Its facts stop being watched, so a leak of a copy you already shared is no longer caught, and it stops counting against your plan. A document whose facts have recorded leaks is kept, with a 409 whose trigger_count says how many; they are your evidence of the leak, and the documents list says which documents that applies to in delete_needs_force. To delete one with its leak records, delete it from the CanaryRAG console, signed in. An API key cannot delete leak records, whatever its scopes: a forced delete sent with one is refused with a 403, because a key is the credential most likely to leak along with the documents it guards. The console asks again with the new count if the document holds another number when the delete starts, and a delete never takes more leak records than it names. Separately, the text of a document, and the copy with the facts in it, is cleared after your account's retention window (365 days by default); the document and its facts stay, so a later leak is still caught.

bash
curl -sS -X DELETE "https://api.canaryvaults.com/canaryrag/documents/<your-user-uuid>/<document-id>" \
  -H "X-API-Key: $CV_KEY"
# → {"ok": true, "deleted": true, "document_id": "…", "deleted_triggers": 0}
# or, while its facts hold recorded leaks, nothing is deleted:
# → 409 {"detail": "This document has 2 recorded leaks; …", "trigger_count": 2}

Documents, checks, and the daily and per-minute allowances

Two metrics meter CanaryRAG, each of them at more than one horizon. rag_documents is a total (how many trap documents may exist on the account at once), and it is 2 on Free, 25 on Shield, 200 on Pro and 500 on Enterprise; deleting a document gives its place back. rag_checks_per_month is a monthly meter, spent by every /canaryrag/monitor call: 10 on Free, 500 on Shield, 2,000 on Pro and 6,000 on Enterprise. Either one answers a structured 402 naming the metric, your plan and your reset date.

Underneath the monthly quota sits a shorter per-account daily allowance, and it is the one you are likely to meet first. Monitor checks are capped at 5 a day on Free, 40 on Shield, 100 on Pro and 300 on Enterprise; injections at 2 a day on Free, 5 on Shield, 20 on Pro and 50 on Enterprise. You can hit either while the monthly quota still has plenty left (100 checks a day on Pro reaches the 2,000 monthly figure only across 20 days), so a backfill that would fit comfortably inside a month can still be refused this afternoon. The daily 402 names the same metric, with resets_at set to the next UTC day rather than the first of next month.

A document may be up to 500,000 characters, and so may an answer sent to the monitor. These two calls take a request body of up to 3 MiB, which holds 500,000 characters of Telugu, Hindi or Chinese whether they are sent as UTF-8 or as JSON escapes; emoji and rare characters take twice the room as escapes. A larger body is refused with a 413. A language model reads a document whole when it can; a longer one is read as up to eight excerpts spread through it, the three facts go into sentences taken from them, and the whole document still comes back. A long answer is checked in overlapping parts, up to six, for one check from your allowance: that covers English to the 500,000-character limit, Telugu or Hindi to about 65,000 characters and Chinese to about 55,000. A longer answer is refused with a 413 before anything is taken from your daily or monthly allowance, so send it in parts. When the model gives no usable answer the call is a 502, and what it took from your daily and monthly allowances is given back; its place in the per-minute allowance is not.

The daily meter is the one thing here that fails closed. If your usage cannot be checked the call answers 503 with daily_quota_unavailable rather than running unchecked. Retry shortly, and read it as not run rather than as refused.

Above both sits a per-minute burst limit, again per metric and per plan: monitor checks allow 10 a minute on Free, 60 on Shield, 240 on Pro and 600 on Enterprise, and injections 2, 5, 20 and 50. Crossing it answers 429 with retry_after_seconds and a Retry-After header rather than a 402. The burst check runs before either meter, so a request it turns away has spent no quota and needs no refund. Which of the two you meet depends on the metric: for monitor checks the daily allowance is the smaller number on every plan, so the day runs out inside a single minute of full-rate calling, while for injections the per-minute and daily figures are identical on every plan, so a burst is turned away by the 429 first and the daily 402 only arrives once the minute has rolled over.