Verify · emem-guard
emem-guard checks every emem citation in a draft before it goes out, and answers allow or deny with a code and a fix.
Each citation is looked up in the signed record. It catches a number that disagrees with the fact it cites, a citation pinned to the wrong place, and, if you ask, a measurable claim that cites nothing.
POST /v1/guard/verdictemem_guard_verdictPaste a sentence with a citation in it, or pick an example. The answer comes from emem.dev, which runs the same engine as a self-hosted node in advisory mode: it tells you what a guard would say and blocks nothing.
Press the button. The response comes from emem.dev, not from this page.
curl -sS -X POST https://emem.dev/v1/guard/verdict \
-H 'content-type: application/json' \
-d '{"texts":["Bengaluru air temperature is 35 degC (emem:fact:defi.zb493.zezo.zcb35:nflpddk7zsncywguwjzk5koksseqfyx4jnngkuryrnd4aykqlpfq)."]}'Recorded on 2026-09-29. The fact reads 28.0 degC, so the draft is denied, and the receipt (left out here) signs the fact cids the responder read to decide:
{
"action": "deny",
"code": "PROV_VALUE",
"fix": "correct_value",
"reason": "EMEM-GUARD DENY PROV_VALUE token=emem:fact:defi.zb493.zezo.zcb35:nflpddk7... fix=correct_value leaf=-",
"citations_found": 1,
"checked": 1,
"citations_verified_here": 1,
"citations": [{ "state": "verified", "token": "emem:fact:defi.zb493.zezo.zcb35:nflpddk7..." }],
"advisory": true,
"schema": "emem.guard.verdict.v1",
"receipt": { "primitive": "emem.guard.verdict", "fact_cids": ["..."], ... }
}| Field | Read it as |
|---|---|
action, code, fix | the decision, why, and what to do about it; code and fix are absent on an allow |
citations_found, checked | how many tokens were in the text, and how many were looked up. An allow with citations_found: 0 is a clean verdict over nothing |
citations[].state | verified, byte_mismatch, or not_held_here for a token this responder does not hold |
receipt | this responder's Ed25519 signature over the fact cids it read; check it at /verify or POST /v1/verify_receipt |
Send texts, not text: a body with no texts field is read as empty and allowed. A chat transcript can go in messages instead. Other envelopes (MCP, OpenAI, CloudEvents, OPA) have their own routes, listed in section 05.
Rules run in order of how much they establish, and the first one that matches decides. Every deny names one code and one fix.
| Code | Fires when | Fix | Default |
|---|---|---|---|
PROV_SIG | a cited fact's signature does not verify | refresh_token | on |
PROV_BYTES | the cited fact exists, but under a different cell than the token names | remove_reference | on |
PROV_VALUE | the number beside a citation is not the number inside it | correct_value | on |
POLICY_MODULE | a detection module you loaded denied (section 08) | set by the module | no modules |
PROV_DRIFT | a cited reading has moved past its band's threshold | refresh_token | on |
GEO_ZONE | a cited cell is on the operator's restricted list | contact_admin | off; self-hosted only |
CLAIM_UNGROUNDED | a measurable claim about a place cites nothing that resolves | cite_observation | off |
PROV_VALUE judges agreement at the precision the writer chose, so 889.6439208984375 written as 889.6 m passes and 5000 m does not.
It fires only when the node read a numeric value for the citation and the sentence carries exactly one citation, so it never has to guess which number goes with which token. A citation placed right after the sentence it supports also counts.
PROV_SIG and PROV_DRIFT are in the contract and the rule engine, and their tests pass, but the resolvers that ship (none, a responder over HTTP, and the hosted one) look a fact up by its content address and never report a failed signature or a drift. A lookup by content address already refuses altered bytes, which is what PROV_BYTES and a not_held_here state report.Relevance. A citation that resolves is not proof it is about what the sentence says.
On 2026-09-28 "Bengaluru rainfall is 28.0 mm" citing the temperature fact above was allowed: the value matched and nothing compared rainfall with weather.temperature_2m. Compare the resolved band and place with the sentence yourself.
Content safety, personal data, secrets. The engine is a chassis for those. A node you run can load modules that detect them; the hosted route loads none.
Tokens it cannot find. A citation this responder does not hold comes back not_held_here and is allowed. It cannot be told apart from one minted by another responder, and denying it would punish an agent for citing somebody else's node.
Use the hosted route to check your own drafts. Run a node when a guard has to sit in a request path and stop something.
| emem.dev | A node you run | |
|---|---|---|
| blocks anything | no, advisory: true | yes, or shadow mode to measure first |
| resolves citations | against its own store, directly | against a responder you name with --responder, or not at all |
| signed by | the emem.dev responder key | a key the node generates on first run |
| evidence | a read receipt over the fact cids it read | a hash-chained verdict log, one leaf per verdict |
| claim gating | per call, claim_gating: true | a node setting, --claim-gating |
| restricted zones, modules | no | yes |
| vendor hooks | no: /verdict/anthropic-hook answers 501 requires_your_own_node | yes |
/log/*, /modules | 501 no_verdict_log_here | yes |
The vendor-neutral routes answer on both with the same bodies, so a client written against your node can be pointed at emem.dev and back by changing the host.
One binary, no config file. The first run writes a signing key and opens a log in the data directory.
$ curl -s https://emem.dev/v1/guard/selfhost | jq -r .skill > SKILL.md
$ git clone https://github.com/Vortx-AI/emem && cd emem
$ cargo build --release -p emem-guard
$ cargo test --release -p emem-guard
$ ./target/release/emem-guard --responder https://emem.dev --data ./var/guardWithout --responder the node signs and logs every verdict and verifies no citation, and its banner says so:
emem-guard listening on 127.0.0.1:8080
signer zwkuk6ahc6qseltbq7expaz2omd2vjkqjhv5criovu53sqy7qmwa
log ./var/guard/verdicts.jsonl (0 verdicts)
resolve null
no responder configured: citations are not verified, only logged.
point one with --responder http://127.0.0.1:5051 to check them.
mode enforce
verify emem-guard --audit --data ./var/guardWith a responder, each citation costs one GET /v1/facts/{cid}. All citations in a transcript share a 400 ms budget, at most 64 are looked up, and anything past either limit counts as unresolved, which is an allow, never a deny.
GET /health reports signer_b32, verdicts_logged, log_head, mode, resolver, require_signature, the four rule switches, and verifies_citations, so a load balancer and an auditor read the same answer to "does this node check anything".
Unit tests prove the handlers, not the server you stood up behind a proxy.
--conformance runs twelve checks over the wire against a running node and exits non-zero on any failure: health honesty, verdicts for unknown events and malformed and empty bodies, a large body accepted, a complete descriptor, every open route answering, idempotent replay, a fetchable leaf that verifies, an answer inside 1000 ms, and 100 requests at a concurrency of 16.
$ ./target/release/emem-guard --conformance https://guard.your-org.exampleA checkpoint platform typically refuses private and loopback addresses and does not follow redirects, so put the node behind TLS on a routable host. Proxies in front of it need their body limit raised too: nginx defaults to 1 MB.
For a checkpoint webhook, a non-200, a timeout or an unparseable body is a transport failure, not a deny.
Under fail-open the request goes through unchecked, and repeated failures can trip the caller's circuit breaker. So every input gets a 200 and a verdict, including an unknown event type, a malformed body and an empty one. The node answers within an 800 ms self-budget and deduplicates on the request id, so a retry gets the answer already recorded.
One engine behind nine request shapes. A test asserts that every route reaches the same verdict on the same evidence.
| Route | Called by | On emem.dev |
|---|---|---|
POST /verdict, /verdict/native | any agent: texts or messages | yes |
POST /verdict/mcp | an MCP host or proxy, gating a tool call or its result | yes |
POST /verdict/openai | an OpenAI-shaped chat or moderations body | yes |
POST /verdict/cloudevent | CloudEvents 1.0 producers such as Knative, Dapr, Argo Events | yes |
POST /verdict/policy | OPA-style {input} clients, Envoy external authorisation | yes |
POST /verdict/batch | an offline scan, up to 64 transcripts per call | yes |
POST /verdict/anthropic-hook | Anthropic Inference hooks, signed with your organisation's secret | no, 501 |
POST /verdict/claude-code | Claude Code client-side hooks | no, 501 |
POST /v1/guard/verdict?shape=mcp (or openai, cloudevent, policy) reads and answers the same envelopes on the one hosted route.
Put a proxy between the host and its servers and ask before dispatching.
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"read_file","arguments":{"path":"notes.md"}}}
{ "allow": true, "id": 7,
"verdict": { "action": "allow", "checked": 0 } }On a deny the response carries result, already shaped as a CallToolResult with isError set.
Return it in place of running the tool and the model reads the denial and the fix where it reads tool errors. Send params.result instead of params.arguments to check a tool's output before the model reasons on it.
The other rules deny when a check fails. This one denies when no check was offered: a sentence states a measurable quantity about a place or time and cites nothing that resolves.
Because it denies on absence, it ships off.
Turn it on for one hosted call with claim_gating: true, as the uncited-claim example in section 01 does. On your own node, run it in shadow first: every rule runs, is signed and logged, and nothing is blocked.
$ ./target/release/emem-guard --claim-gating --shadow --data ./var/guard
$ ./target/release/emem-guard --report --data ./var/guard
verdicts 5
blocked 0
would have blocked 1
fired rate 0.2000
citations checked 2
...
by code enforced / observed
CLAIM_UNGROUNDED 0 / 1would have blocked is counted from the same append-only file an auditor reads, and GET /log/report serves the same counts. When the rate is acceptable, drop --shadow. The evaluation and the signed record stay the same; only the answer to the caller changes.
A sentence fires only if its unit is in a table where every row names the band that reports it: metres from copdem30m.elevation_mean, mm/day from weather.precipitation_mm, µg/m³ from cams.pm25.
The verdict names that band as claim.source_band. Bytes, seconds, hertz and currency have no band, so "the call took 800 ms" never fires.
Three more conditions must hold. The sentence has a world anchor (an ISO date, a year, a coordinate pair, or a preposition before a capitalised name). It is an assertion, not a question, hedge or conditional. It is prose, not code or configuration.
A citation that resolves clears the rule, and then the value rule applies to it. The uncited Sumatra sentence above, given Lusail's elevation fact as its citation, is no longer CLAIM_UNGROUNDED; it is PROV_VALUE, because that fact does not say 31 m.
A node you run signs every verdict and appends it to its log before the caller gets an answer. A denial names the leaf that records it.
{
"seq": 0,
"record": {
"checkpoint": "emem.native.v1",
"request_id": "01KZAWCH25KXHYHAJHWK03151D",
"outcome": "allow",
"evaluated": "allow",
"mode": "enforce",
"checked": 1,
"decided_at_unix_s": 1785997968
},
"signature_b32": "p4cfqpvlyymax6wg5lrshfn2t7tt6stw73saprg3oi57pvdbl7gri...",
"signer_b32": "zwkuk6ahc6qseltbq7expaz2omd2vjkqjhv5criovu53sqy7qmwa",
"chain": "7a99eac5a05dac1a8b0e16ee9867d1daa03ac3aa55ff72ab77d10086abfd816f"
}outcome is what the caller was told and evaluated is what the rules reached. Both are signed, so an operator cannot later relabel a shadow verdict as enforced.
Signatures prove each entry; the chain proves none was removed.
emem-guard --audit prints the entry count and any bad signature, broken link or unparseable line, and exits 1 if the log is not intact, so a scheduled audit fails its pipeline. Edit one verdict from allow to deny and it reports that entry under both bad signature and broken chain. Delete a line and every remaining signature still verifies; the chain breaks at the gap.
$ emem-guard --audit --data ./var/guard
entries 5
bad signature []
broken chain []
unparseable []
intact trueGET /log/head publishes the head for a witness to pin, and GET /log/entries?start=0 lets anyone mirror the log.
Grounding is built in. Other detection plugs in, and a module's verdict is signed and logged like a native one.
| Load with | What it is |
|---|---|
--module secret-patterns | built in: fixed-prefix credential shapes such as AKIA and ghp_; denies with POLICY_MODULE, fix redact_and_retry |
--module webhook:URL | your own classifier over HTTP, 200 ms timeout; abstains when it times out |
--module sidecar:PATH | one line of JSON each way over a unix socket, for a closed engine that will not link against this binary |
--signed-module FILE with --trust-publisher KEY | a module whose manifest a publisher signed; nothing loads without a key the operator typed |
A module declares a latency class and a data class.
A slow module never runs on the enforcing path, a fast one that misses its budget is demoted and stops being able to block, and an abstain is never a deny. --modules-no-text withholds transcript text from every module. The log records the module id, version and an evidence digest, never the matched content.
Choices that could have gone the other way.
| Decision | Reason |
|---|---|
| A token the node cannot find is never a denial. | A node holds a subset of any corpus. From inside it, a token minted elsewhere and a forged one look the same, and a wrong deny is the worst failure a guard has. |
| Every uncertain case allows, including a bad signature on an inbound webhook. | A non-200 is a webhook failure, which under fail-open sends the request through unchecked and can trip the caller's breaker. The node answers 200 with allow and logs the problem. |
| Claim gating ships off. | It denies on absence. The detector, its conditions and the unit table are public, and shadow mode lets you measure it on your own traffic first. |
| The hosted route blocks nothing and loads no modules. | A shared node that blocked other people's traffic, or ran somebody's classifier over their transcripts, would be a different product with a different trust story. |
If one of these is wrong, say so on the channel. More diagrams of the guard are in the diagram gallery.