Task-oriented recipes with copy-pasteable commands. Every command below is
verified against the areev CLI. Run areev help for the full usage summary.
Conventions used throughout:
- Every command needs
--db <file>(the memory file). It is created on first write. --ns <namespace>partitions grains within a file; it defaults toshared. On reads it also takes a prefix scope:--ns 'org.*'selectsorgplus its.-descendants (org.sales,org.sales.emea— neverorganization). Writes and destruction always take one exact namespace.-k <N>caps result counts.
If you are running from source instead of an installed binary, replace areev
with cargo run -p areev --.
Store a fact (subject–relation–object), then read it back:
# Add a fact (confidence defaults to 0.9)
areev add --db john.db --ns caller \
--subject john --relation prefers --object "window seat" --confidence 0.95
# Recall everything about a subject, newest-first (JSON lines)
areev recall --db john.db --ns caller --subject john
# Narrow to one relation, cap results
areev recall --db john.db --ns caller --subject john --relation prefers -k 5add prints the new grain's content address (64-hex). Fetch any grain by hash:
areev get <hash> --db john.dbInstead of raw JSON, render recall results into context for a model, under an optional token budget:
areev recall --db john.db --ns caller --subject john --render sml
areev recall --db john.db --ns caller --subject john --render markdown --budget 300--render accepts sml, toon, markdown, plain, or json. A one-line
summary (grain count, estimated tokens, whether it was truncated) is printed to
stderr.
search runs hybrid recall (structural + BM25, fused with RRF):
areev search --db john.db --ns caller --query "seat preference" -k 10CAL (Context Assembly Language) is Areev's query language. It has no bulk
destruction — DELETE/DROP are not tokens in the grammar; the only
destructive statement is FORGET <hash> (a single-grain tombstone, gated —
disable with --no-destructive-ops).
# Count matching facts
areev cal 'RECALL facts WHERE subject = "john" | COUNT' --db john.db --ns caller
# Recall with a filter
areev cal 'RECALL facts WHERE subject = "john" AND relation = "prefers"' \
--db john.db --ns caller
# Scope a recall to a namespace subtree ("org" + every .-descendant), or an
# explicit set — the same "org.*" convention works on --ns and in MCP
areev cal 'RECALL facts WHERE namespace = "org.*" AND subject = "acme-hq"' --db org.db
areev cal 'RECALL facts WHERE namespace IN ("org.sales", "personal") AND subject = "john"' --db org.db
# Add through CAL (ADD requires a REASON/BECAUSE clause)
areev cal 'ADD fact SET subject = "john" SET relation = "likes" SET object = "rust" REASON "session note"' \
--db john.db --ns caller
# Assemble one prompt from several sources in a single statement
areev cal 'ASSEMBLE "prompt" FROM
policies: (RECALL facts WHERE namespace = "org.policies" AND subject = "refunds"),
profile: (RECALL facts WHERE subject = "john")' --db john.db
# Ask whether a specific grain exists, or view a subject's history
areev cal 'EXISTS sha256:<64-hex>' --db john.db
areev cal 'HISTORY WHERE subject = "john" AND relation = "prefers"' --db john.db --ns callerFor an interactive shell (with .stats, .log, .verify, .help, .quit
dot-commands):
areev repl --db john.db --ns callerSee cal-reference.md for the full language.
Areev ships a built-in MCP server on stdio. Register it with Claude Code in one line:
claude mcp add areev -- areev serve --mcp --db ~/.areev/code.db --ns claude-codeThis exposes 6 tools to the model: areev_recall, areev_add,
areev_supersede, areev_forget, areev_remember, and areev_cal.
Any MCP client can launch the same server directly:
areev serve --mcp --db ~/.areev/code.db --ns claude-codePrint a ready-made hook snippet for ~/.claude/settings.json (it only prints —
it never edits your config):
areev hook claude-code --db ~/.areev/code.db --ns claude-codeThe snippet wires the Stop hook to areev capture-stop, which reads Claude
Code's hook JSON on stdin and stores the last exchange as thread-indexed Event
grains.
See mcp-reference.md for the tool schemas.
Add --passphrase-env <VAR> to any command to encrypt the database at rest.
Areev derives an AES-256 key (Argon2id) from the passphrase held in the named
environment variable; the non-secret salt is kept in a <db>.kdf sidecar.
# Keep the passphrase in the environment, never on the command line
export AREEV_PASS='correct horse battery staple'
areev add --db secret.db --passphrase-env AREEV_PASS \
--ns caller --subject john --relation prefers --object tea
areev recall --db secret.db --passphrase-env AREEV_PASS --ns caller --subject johnBack up the secret.db.kdf sidecar alongside secret.db — without it the key
cannot be re-derived.
Caveats: the
.blobssidecar (large binary payloads) is not encrypted, and encryption-at-rest uses the storage engine's AES-256-GCM, an experimental Turso feature. Treat it as defense-in-depth, not a substitute for full-disk encryption. Read../SECURITY.mdfirst.
A bundle is a portable, incremental, git-shaped backup of the op-log.
# Write a full backup
areev bundle --db john.db --out john-backup.mgb
# Apply it to another file (fast-forward, idempotent)
areev import --db restored.db --bundle john-backup.mgbFor incremental backups, bundle prints the cursor for the next run — pass it
back as --since:
areev bundle --db john.db --out inc-01.mgb # prints: next --since <N>
areev bundle --db john.db --out inc-02.mgb --since <N> # only new opsInspect the change feed at any time:
areev log --db john.db --limit 20
areev verify --db john.db # integrity + full content-address recheck
areev stats --db john.dbstream continuously ships op-log segments (with generations, Litestream-shaped)
to a directory — a local path, an NFS mount, or an object-store mount. follow
subscribes and applies new segments; restore rebuilds from scratch, including
point-in-time restore.
# Producer: keep shipping changes to a shared directory
areev stream --db john.db --to ./sync-dir --interval-ms 500
# Consumer: subscribe and apply new segments as they appear
areev follow --db replica.db --from ./sync-dir --interval-ms 1000
# One-shot variants for scripts/cron
areev stream --db john.db --to ./sync-dir --once
areev follow --db replica.db --from ./sync-dir --once
# Rebuild a fresh file from a stream dir, optionally to a point in time
areev restore --db new.db --from ./sync-dir
areev restore --db new.db --from ./sync-dir --until-hlc <HLC>Because grains are content-addressed and imports are idempotent, concurrent edits that arrive out of order become branches (heads) with a deterministic provisional head rather than lost writes.
Install the published package:
pip install areevOr build from a local checkout with maturin:
pip install maturin
maturin develop -m crates/areev-py/Cargo.toml # into the active virtualenvThen:
import areev, json
m = areev.Areev("john.db", ns="caller")
# Add facts (returns the 64-hex content address)
h = m.add_fact("john", "prefers", "window seat", confidence=0.95)
# Structural recall and CAL both return JSON strings
print(m.recall("john"))
print(m.cal('RECALL facts WHERE subject = "john" | COUNT'))
# Current head for a (subject, relation); evolve it with supersede
head = m.latest("john", "prefers")
m.supersede(h, "fact", json.dumps({
"subject": "john", "relation": "prefers", "object": "aisle seat"
}))
# Full history, portable backup, integrity check
print(m.history("john", "prefers"))
m.bundle("john-backup.mgb", 0)
print(m.verify())
# Anthropic memory-tool backend, scalars in / JSON string out
print(m.memory_tool(json.dumps({"command": "view", "path": "/memories"})))The bindings follow scalars in, JSON strings out; errors raise
ValueError. Encryption at rest is available from both bindings, not just the
CLI — pass a passphrase to the constructor
(areev.Areev("john.db", ns="caller", passphrase=os.environ["AREEV_PASS"]) in
Python, new Areev("john.db", "caller", pass) in Node). It derives an
AES-256 key with Argon2id exactly as the CLI's --passphrase-env does; the key
is host-supplied and never stored in the file, and the .blobs CAS sidecar is
not covered (see open_warnings()).
ui serves a local, browser-based console (memories, graph, and query tabs;
light + dark themes; grain inspector):
areev ui --db john.db
# → areev console → http://127.0.0.1:7437The console binds loopback (127.0.0.1:7437) with no authentication by
design. It refuses to bind a non-loopback address unless you pass
--allow-remote — and even then serves an unauthenticated, writable console over
plaintext HTTP, so only do that behind a TLS-terminating reverse proxy with its
own auth:
# Choose a different loopback port
areev ui --db john.db --addr 127.0.0.1:8080
# Override the loopback guard (NOT recommended — front it with a TLS proxy + auth)
areev ui --db john.db --addr 0.0.0.0:8080 --allow-remote--allow-remote alone gets you a console you can see but not use: the
Origin check still rejects every POST, and CAL runs by POST. That check is
CSRF protection, not a formality — browsers cache HTTP Basic credentials and
re-attach them to cross-site requests, so Origin is what tells the console's
own page apart from an attacker's page riding a viewer's cached login. Name
your public origin instead of stripping the header at the proxy:
export AREEV_TOKEN=$(openssl rand -hex 32) # entropy IS the control here
areev ui --db john.db \
--token-env AREEV_TOKEN \
--allow-origin https://console.example.com \
--read-only--allow-origin takes a comma-separated list and matches exactly —
scheme + host[:port], no wildcards, no subdomains — so naming
https://console.example.com never also admits
https://evil-console.example.com.
--read-only is worth adding to any console that is only for looking. On the
Postgres backend it is what makes a least-privilege database role possible at
all: without it, opening a memory runs schema bootstrap and index maintenance
on every open, so the connecting role must own the schema — see
deployment-profile.md for the GRANT recipe.
--token-env is one shared secret: everyone who holds it is the same
anonymous admin, every audit grain says user:console, and nobody can
approve a HITL ask with it — an approval's whole value is naming who
approved. For anything past a single-operator console, mint per-principal
credentials instead:
# One credential per human or agent. The token prints ONCE, on stdout.
areev auth mint --auth areev-auth.json \
--id officer-laptop --principal "user:officer" --expires 90d --label "refund approvals"
areev auth mint --auth areev-auth.json \
--id ci-runner --principal "agent:ci" --memories ops.db
areev auth list --auth areev-auth.json # ids, principals, expiry — no secrets
areev auth revoke --auth areev-auth.json --id ci-runner # then restart the console
areev ui --db ops.db --auth areev-auth.jsonThe file stores only each token's SHA-256, so a stolen or synced copy is inert. Rights come from the memory file's grants, not the map:
areev cal --db ops.db \
'GRANT read,run.respond ON ops TO "user:officer" WITH because("refund approvals")'Revoking one --id leaves that principal's other credentials working, which
is what makes "the CI token leaked" a five-second fix instead of a rotation of
everything. Expiry is optional; when set, an expired credential is refused
exactly as an unknown one is.
For humans, put an authenticating proxy in front (--sso-header), or build
with --features oidc and let the console run the OIDC flow itself —
runbooks/oidc-setup.md covers which to choose and
why it matters for approvals.
Rate limiting belongs at the proxy, not in areev: the console serves one
connection at a time, and it only ever sees the proxy's IP. areev logs each
failed auth as areev: console auth FAILED from <ip> (<n> consecutive) so you
have something to write a fail2ban-style rule against.
See ../SECURITY.md for the trust model and the operator
hardening checklist, and security-model.md for the
console's auth surface in detail.
remember stores raw content as an Event grain (it prints the hash) and
attaches the facts distilled from it. You supply those facts, or you let a model
extract them.
Every surface that captures raw text writes the same grain through the same
path — areev remember, the Python and Node bindings, the MCP areev_remember
tool, and the capture-stop hook. Pass --session-id and --role to record a
turn as part of its conversation (RECALL events WHERE session_id = "...").
Host-supplied — you already ran your own extractor, so no model is called:
areev remember --db john.db --ns caller \
--content "I always want a window seat, and I'm vegetarian." \
--observer voice-agent \
--facts '[{"subject":"john","relation":"prefers","object":"window seat","confidence":0.9},
{"subject":"john","relation":"diet","object":"vegetarian","confidence":0.95}]'Model-extracted — --model provider:name uses a built-in backend (key read
from the environment: OPENAI_API_KEY / ANTHROPIC_API_KEY, or ollama: for
a local model, never on the command line). --llm-cmd 'CMD' is the
zero-dependency escape hatch: your command gets the request JSON on stdin and
prints the response.
areev remember --db john.db --ns caller \
--content "I always want a window seat, and I'm vegetarian." \
--observer voice-agent \
--model openai:gpt-4o-mini{"event":"a1b2…","facts":["c3d4…","e5f6…"],"model":"gpt-4o-mini",
"verification_status":"unverified","proposed":2,"dropped":0}Extraction is the point where a model can write its own hallucinations into memory, so the write is shaped to stay honest about that:
-
The raw text lands first. The Event is written before the model is called. A failed or garbage extraction costs you the facts, never the source — the hash is still printed, and you can retry the extraction against it.
-
Extracted facts are marked, not trusted. Each one carries
derived_from(the event),source_type=derived,extractor_model(which model wrote it), andverification_status="unverified". That last one is CAL-filterable, so the extraction queue is reviewable:areev cal 'RECALL facts WHERE verification_status = "unverified"' --db john.db --ns caller
-
Grounding is opt-in.
--ground-model/--ground-cmdruns a separate call that checks each proposed fact against the source text — proposer ≠ scorer, the same rule the Areev Loop verifier follows. Facts the grounder does not support are dropped; survivors are stamped"verified". -
Nothing is dropped silently.
proposedvsdroppedin the output account for everything the confidence floor (--min-confidence) and the grounder removed.
Use --dry-run to see what a model would extract without writing anything —
useful for iterating on --extract-hint, which steers extraction toward your
domain ("only travel preferences; ignore scheduling chatter").
The same knobs exist on the bindings (model=, llm_cmd=, ground_cmd=,
extract_hint=, min_confidence= in Python; the camelCase equivalents in
Node). MCP's areev_remember deliberately has none of this — there the client
is a model, so it stores the exchange as an Event and distills with
areev_add.
The Anthropic memory-tool backend maps a /memories/... file space onto
supersession chains of Fact grains (full wiring guide:
memory-tool.md):
areev memtool '{"command":"view","path":"/memories"}' --db john.db --ns caller
areev memtool '{"command":"create","path":"/memories/notes.md","file_text":"prefers window seat"}' \
--db john.db --ns callerThis section builds the loop manually — you drive reflection and the writes. For the same loop governed — deterministic analyzers that find duplicates, contradictions, recurring tool failures, and stale lessons, each as a reviewable, undoable, audited recommendation — see §12 Self-improve with Areev Loop and docs/loop.md. Areev Loop sits on exactly the substrate below.
A self-improvement loop is: act → log experience → reflect → distill lessons
→ recall them next time. Areev is the substrate for that loop, not the loop
itself: reflection (deriving lessons from experience) is a model call your host
makes, like all LLM work. What the store guarantees is that learning cannot rot
the memory it feeds on silently — revised lessons replace instead of
co-ranking (supersession), every lesson links to the experience that taught it
(derived_from + REASON), replayed or re-synced writes are idempotent
(content addressing), and a bad learning episode can be rolled back
(point-in-time restore). One honest limit: a paraphrased re-learning is new
bytes and therefore a new grain — content addressing alone cannot know it's a
duplicate. For that, areev novelty gives an advise-mode check (below): the
harness looks up the nearest existing lesson before writing and supersedes it
instead of adding a paraphrase. In a learning loop these properties are not
hygiene: rot compounds, because the agent keeps learning from its own mistakes.
Log experience as it happens — remember stores each entry as an Event grain
and prints its hash (keep it: the lesson below links back to it). Without
--model no model is called, so this stays a pure write — log everything:
areev remember --db agent.db --ns agent --observer executor \
--content "Task: fix flaky test. Attempt 1: reran without isolation - FAILED."
areev remember --db agent.db --ns agent --observer executor \
--content "Task: fix flaky test. Attempt 2: isolated the shared tempdir per test - PASSED."Read experience back for reflection with areev log --db agent.db (op-log,
newest ops last) and areev get <hash> per grain. Write-cost note: the
microsecond write path assumes the text index is off or deferred — a live FTS
index costs ~140ms/write (RESULTS.md finding #1). For high-volume experience
logging, open with --index-text false and areev reindex before
recall-heavy phases, or keep raw experience and lessons in separate files.
After an episode, reflect (your model call) over the recent experience and
store each distilled lesson as a fact keyed to the skill it belongs to.
derived_from links the lesson to the observation that taught it —
structural provenance, not just a comment — and REASON records why:
areev cal 'ADD fact SET subject = "fix_flaky_tests" SET relation = "lesson"
SET object = "Flaky tests sharing a tempdir need per-test isolation; rerunning alone never fixes them."
SET confidence = 0.7 SET derived_from = "<observation-hash>"
REASON "distilled from session flaky-01"' --db agent.db --ns agentTrack proficiency as its own supersession chain — ADD once, then SUPERSEDE
the tip after each success (both print the hash you supersede next time):
areev cal 'ADD fact SET subject = "fix_flaky_tests" SET relation = "proficiency"
SET object = "0.30" REASON "first successful fix"' --db agent.db --ns agent
areev cal 'SUPERSEDE sha256:<tip-hash> SET object = "0.55"
BECAUSE "second successful fix, different repo"' --db agent.db --ns agentRecall surfaces only the current value — no stale value co-ranks with a
revised one. (That guarantee holds within a supersession chain: two
independently ADDed facts on the same subject both surface as current, so
revise, don't re-add.) The full learning curve — every level and the reason it
changed — is one query; per-version wall-clock rides the op-log (areev log),
since supersession carries the original created_at forward:
areev cal 'HISTORY WHERE subject = "fix_flaky_tests" AND relation = "proficiency"' \
--db agent.db --ns agentAt act time, pull the lessons back into the model's context:
areev search --db agent.db --ns agent --query "flaky test" -k 5
areev recall --db agent.db --ns agent --subject fix_flaky_tests --render sml --budget 300Unlearning is what makes the loop safe to run unattended. A single bad lesson is superseded (revised) or forgotten (tombstoned) by hash.
For an episode-scoped unlearn — undo everything the agent distilled from one
bad session without losing the good writes around it — link each lesson to its
source experience with SET derived_from = "<observation-hash>" (shown above),
then walk it back with areev provenance:
# Which lessons came from this observation/session? (reverse provenance)
areev provenance <observation-hash> --db agent.db --ns agent
# Revise or tombstone each returned hash — e.g. forget them all:
areev provenance <observation-hash> --db agent.db --ns agent \
| python3 -c 'import json,sys; [print(json.loads(l)["hash"]) for l in sys.stdin]' \
| while read h; do areev cal "FORGET sha256:$h" --db agent.db --ns agent; doneareev provenance is precise (only grains derived from that source) and keeps
the surrounding good writes intact — the credit-assignment tool for a learning
loop. When you instead need to roll the whole file back to a point in time,
checkpoint before risky learning and rewind (this also discards good writes in
the window, and produces a new file you swap in):
areev stream --db agent.db --to ./checkpoints --once # checkpoint: ship the op-log
# ... a bad reflection episode writes junk lessons ...
areev log --db agent.db # note the HLC of the last good op
areev stream --db agent.db --to ./checkpoints --once # ship the rest, then rewind:
areev restore --db rewound.db --from ./checkpoints --until-hlc <HLC>For a typed capability record there is also the OMS Skill grain:
areev cal 'ADD skill SET name = "fix_flaky_tests" SET description = "Diagnose and fix flaky tests"
SET when_to_use = "test passes alone but fails in suite" SET confidence = 0.3
REASON "first successful fix"' --db agent.db --ns agentA Skill's confidence is its proficiency (OMS aliases them), and it
carries definition fields like instructions and when_to_use. Evolve it with
SUPERSEDE sha256:<hash> SET confidence = 0.55 BECAUSE "..." — unchanged
fields carry forward — and fetch any version with areev get <hash>. Skill
grains are hash-addressed records today; keep the queryable index in facts,
as above.
The steps above are the mechanics; two hooks make the loop run without you
thinking about it. areev hook claude-code prints a settings.json snippet
that wires both directions:
areev hook claude-code --db ~/.areev/code.db --ns claude-code # prints, never writesUserPromptSubmit→areev recall-hookreads each prompt, hybrid-searches memory, and prints matching lessons to stdout — which Claude Code injects as context. Retrieval stops depending on the model choosing to call a tool.Stop→areev capture-stopstores the turn's last exchange as Events, including tool calls and their outcomes (a failingtool_resultis captured and flagged), which is the raw signal reflection distills from.
recall-hook reads the hook JSON on stdin ({"prompt": "..."}), so it also
works from any tool that can run a command per prompt; it stays silent when
there is no prompt or no match, so it never adds noise.
The reflect step — turning captured experience into lessons — is a model
call. Areev will make it for you if you point it at a model (areev remember --model for extraction, §9; areev loop run --model for governed
reflection), but nothing runs one by default, and the harness below keeps the
call entirely in your hands. The shape of a nightly (or on-SessionEnd)
harness:
# 1. Pull recent experience (now that the experience log is recallable):
areev cal 'RECALL events RECENT 100' --db agent.db --ns agent --render plain > episode.txt
# 2. Distill lessons with YOUR model (any stdin→stdout command), e.g.:
# claude -p "Read this session. Emit each durable lesson as one line:
# subject | relation | object | derived_from=<observation-hash>"
cat episode.txt | claude -p "$(cat reflect-prompt.txt)" > lessons.tsv
# 3. Write each lesson back. --idempotent collapses an exact repeat; for
# paraphrases, ask `areev novelty` for the nearest existing lesson first and
# supersede it past a similarity threshold instead of adding a near-dup:
while IFS='|' read -r s r o df; do
near=$(areev novelty --text "$o" --subject "$s" --relation "$r" \
--db agent.db --ns agent --embed-cmd 'my-embedder' -k 1)
sim=$(printf '%s' "$near" | python3 -c 'import json,sys; l=sys.stdin.read().strip(); print(json.loads(l)["similarity"] if l else 0)')
if awk "BEGIN{exit !($sim > 0.9)}"; then
hash=$(printf '%s' "$near" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["hash"])')
areev cal "SUPERSEDE sha256:$hash SET object = \"$o\" BECAUSE \"refined lesson\"" --db agent.db --ns agent
else
areev add --db agent.db --ns agent --subject "$s" --relation "$r" --object "$o" --idempotent
fi
done < lessons.tsvareev novelty is advise-only — it never drops or writes; the harness decides
supersede-vs-add (the exact failure mode of stores that silently deduped and
lost updates). --idempotent handles exact repeats, SET derived_from on
lessons lets areev provenance walk them back. The engine gives you the safe
substrate; the harness is the one piece you own.
Dump your existing memories to a file (per-source one-liners in
migrate.md), then:
areev migrate --from mem0 --file export.json --history history.json --db mine.db
areev migrate --from basic-memory --file ~/basic-memory --db mine.db # notes → /memories/*
areev migrate --from jsonl --file memories.jsonl --db mine.db # pgvector/Chroma/homegrownmem0 history becomes real supersession chains with original timestamps; re-running an import skips what's already there. Check the result:
areev stats --db mine.db
areev search --query "anything you remember" --db mine.db
areev memtool '{"command":"view","path":"/memories"}' --db mine.dbTo embed while importing (vector recall), add
--embed-cmd 'python3 my_embedder.py' — the command reads text on stdin and
prints a JSON array of floats.
§10 builds the loop by hand. Areev Loop governs it: it turns your agent's history into recommendations — evidence-cited, reviewable, undoable, measured — with no model calls. The 60-second proof needs no agent:
import areev, json
db = areev.Areev("proof.db", actor="user:me")
for _ in range(5): db.record_tool_call("stripe_refund", '{"error":"rate_limited"}', is_error=True)
for _ in range(2): db.record_tool_call("stripe_refund", '{"ok":true}', is_error=False)
db.add_fact("acme", "deploy_target", "us-east-1", 0.9)
db.add_fact("acme", "deploy_target", "eu-west-1", 0.9) # a contradiction
db.loop_run() # deterministic; bare = never gated
pending = json.loads(db.recommendations('{"status":"pending"}'))
for r in pending: print(r["severity"], r["summary"])
# model judgment — apply one with a reason, dismiss one with a reason
db.apply_recommendation(pending[0]["hash"], because="retries belong in the client")
db.dismiss_recommendation(pending[1]["hash"], "kept the newer region intentionally")From the CLI, the same loop against a seeded demo backend:
areev init --db demo.db --template demo --ns caller # plants dupes, a contradiction, a stale grain
areev loop run --db demo.db --ns caller
areev loop list --db demo.db --ns caller # the queue
areev loop approve <hash> --db demo.db --ns caller --because "confirmed"
areev loop apply <hash> --db demo.db --ns caller --because "consolidating"
areev ui --db demo.db --token-env AREEV_TOKEN # the Areev Loop tab (token-less = read-only)Ops without a daemon — a run is a cheap idempotent command hosts trigger however they already do (hook, cron, CI):
areev loop run --db agent.db --min-new 20 --min-new-errors 3 --if-stale 6h --quiet # cheap no-op off a watermark
areev loop list --db agent.db --fail-on high --format json # CI gate: exit 2 on matchImport history that predates Areev, auto-apply structural curation via a
policy file, and the multi-agent supervisor pattern each have a runnable
example under ../examples/. Auto-apply is off unless a host
loop-policy.json grants it, and even then it is limited to non-destructive
structural curation with no model- or tool-derived free text. Full guide:
docs/loop.md.
The access request and the erasure run one selector, so what you disclose is exactly what you delete. Three commands, one audit trail:
# 1. Art. 15 access + Art. 20 portability — nothing is modified.
areev subject-report "pat" --db memory.db --ns caller \
--out pat.jsonl --bundle pat.mgb
# 2. Art. 17 erasure — history, partition keys (pat#visit1), dictionary
# entries, and sole-referenced attachments, replicating as tombstones.
areev forget-subject "pat" --db memory.db --ns caller --yes \
--because "Art. 17 request #42"
# 3. Art. 5(2)/30 evidence — who erased what, when, and why.
areev audit export --db memory.db --out evidence.jsonlAdd --text-mentions to both the report and the erasure to reach grains
whose indexed text mentions the identity (needs the text index on and fully
built; it errors rather than answering partially). The .mgb is a portable
bundle any OMS store can import — that is the portability deliverable, not a
screenshot.
The audit record names a fingerprint of the identity, not the identity:
areev audit export --db memory.db | head -1
# {"trail":"destruction","verb":"erase","target":"subject:68d753f055b1a15b ns:caller",
# "subject_ref":"sha256-64/hex","because":"Art. 17 request #42","grains_erased":2,...}
# Verify a record refers to "pat" by recomputing the digest:
python3 -c 'import hashlib; print(hashlib.sha256(b"pat").hexdigest()[:16])'
# 68d753f055b1a15bThat is deliberate: an audit grain is immutable, replicates, and lands in archives, so writing the identifier there would undo the erasure it records. The fingerprint is verifiable from a candidate identity but not enumerable.
To make the erasure reach your archives too, run streaming with a retention window — each checkpoint snapshots the already-erased store, and generations older than the window are dropped whole:
areev stream --db memory.db --to /var/lib/areev/archive --checkpoint --retain 30dFull obligation map, deployment requirements, and honest limits:
gdpr.md.
Bind the host configuration and recall telemetry to one run id, then export only the transcript selected by read-only CAL:
areev run-manifest --db agent.db --run-id eval-42 \
--config '{"model":{"base":"model:v1"},"policy":{"version":"p3"},"seed":7}'
# Use --run-id eval-42 on recall/search/CAL invocations whose full telemetry
# rows should join to this trajectory.
areev corpus --db agent.db --ns caller \
--select 'RECALL events WHERE session_id = "session-42"' \
--out train.jsonl --recipient trainer:model-v2The JSONL row uses OpenAI chat messages and a top-level tools list. Areev
also emits step quality/loss weights, observation elisions, and the binding to
source hashes, model/policy versions, trace, and data-subject fingerprints.
The export receipt is an immutable grain in agent:harness (and therefore
requires write ON agent:harness in addition to the selector's read grant); its
mg:corpus_source links make every input reverse-traversable and let later
subject or retention erasure identify stale corpus files. Those files must be
retired or re-derived—the receipt is not an unlearning claim about model
weights.
Hand that corpus to your trainer — Areev never trains — and govern the resulting adapter through the same gates a memory edit passes:
# The gate first: the evalset hash is the Rule E1 pin.
areev eval create --db agent.db --name support-gate --cases cases.json
# Export + train in one command (or reuse an earlier export with
# --corpus train.jsonl --manifest <hash>). The trainer gets the job spec on
# stdin and AREEV_CORPUS_PATH/AREEV_CORPUS_MANIFEST/AREEV_EVALSET in env,
# and prints an adapter reference on stdout:
# {"adapter": {"uri", "sha256"}, "base_model", "serves_as",
# "quantization"?, "serving_runtime"?, "base_build"?, "metrics"?}
areev tune --db agent.db --cmd 'my-trainer --base qwen3-4b' \
--select 'RECALL events WHERE session_id = "session-42"' \
--out train.jsonl --evalset <PIN>
# Propose → grade → promote → (regret → roll back):
areev loop run --db agent.db # adapter_intake files the rec
areev eval run --db agent.db --evalset <PIN> \
--model openai-compat:<serves_as> # vLLM/SGLang; ollama:<name> for GGUF
areev loop approve <rec> --db agent.db --because "corpus + lineage reviewed"
areev loop apply <rec> --db agent.db --because "gated and green" \
--gating-run <eval-run-id>The apply writes (model:<serves_as>, mg:adapter_promotion) — the host
serves what a live promotion names and stops when it is retracted. Because
the adapter grain's derived_from is the corpus manifest, a later
forget-subject names the stale adapters right beside the stale corpora.
Serving is any OpenAI-compatible endpoint: set OPENAI_BASE_URL (ending in
/v1) and a non-empty OPENAI_API_KEY, or pass --base-url/--key-env.
Prompt-assembly logic can live in the memory file as a named, versioned saved query instead of in your agent's code — so operators re-tune what gets recalled without redeploying anything. Define it once:
areev cal --db agent.db 'DEFINE QUERY "session_prompt"($user, $session)
DESCRIPTION "standard session bootstrap"
AS {
ASSEMBLE "session" FROM
profile: (RECALL facts WHERE subject = $user),
recent: (RECALL events WHERE session_id = $session RECENT 10)
BUDGET 1200 tokens FORMAT sml
}'Any surface can now run it — CLI, MCP (areev_cal), the console's SAVED
list, or the bindings:
areev cal --db agent.db 'RUN "session_prompt"($user = "john", $session = "call-42")'
areev cal --db agent.db 'DESCRIBE QUERIES' # what this file providesThe agent-side pattern (from examples/hermes/): at startup, DESCRIBE QUERIES and prefer a deployment-provided query over composing your own —
redefining session_prompt in the file changes the agent's context on the
next call, no restart. Saved-query bodies are read-only by construction
(no writes, no destruction, no recursion), so handing one to an agent
never widens its authority.
Custom render templates work the same way — defined in the file, applied
with FORMAT TEMPLATE <name>, budget-aware out of the box (ELEMENT
degrades to ELEMENT_SUMMARY as a token budget squeezes, and
ELEMENT_OMIT accounts for what was dropped):
areev cal --db agent.db 'DEFINE TEMPLATE brief DESCRIPTION "one line each"
ELEMENT { - {{grain.subject}} {{grain.content}} }
ELEMENT_SUMMARY { ~ {{grain.subject}} }'
areev cal --db agent.db 'RECALL facts WHERE subject = "john" FORMAT TEMPLATE brief'Both registries are part of the file: they survive a copy, ride areev bundle / stream to every replica (usage timestamps stay local), and
DEFINE/DROP sit behind the admin verb so only governing principals can
change them.
Declare a per-namespace policy once and every model-facing read — recall,
search, CAL, MCP, the graph reads — returns typed placeholders
([PERSON_1], [PHONE_1]) instead of identities. An egress policy also
covers areev run's abstract nodes, which never were a read: the prompt is
pseudonymized and the model's tool-call arguments are rehydrated before the
tool runs, so the model sees a placeholder and the tool still gets the real
value (run.md). The placeholder→value
mapping stays in your process; the model's reply is rehydrated by exact
token replacement. This is pseudonymization, not anonymity — the honest
scope is the egress channel (provider logs, prompt retention).
Try the detector chain with no memory file at all:
areev anonymize scan --text "reach me at j.doe@acme.io, pin number is 1462"
# → detections: email + pin (Tier-0: regex + checksum + keyword cues)Declare a policy; it travels with the file and replicates to synced copies:
areev add --db support.db --ns caller \
--subject caller:john --relation prefers --object "call me at +1 415 555 0142"
areev anonymize set --db support.db --ns caller --policy '{"mode": "egress"}'
areev recall --db support.db --ns caller --subject caller:john
# → subject "[PERSON_1]", object "call me at [PHONE_1]"
# Bare "john" in prose is caught too: the store knows its interned subjects.
areev anonymize list --db support.db # what is declared
areev anonymize clear --db support.db --ns caller # back to raw readsThe round trip in an app (Python; Node is the same surface, camelCased):
m = areev.Areev("support.db", ns="caller")
m.set_anon_policy("caller", json.dumps({"mode": "egress", "scope": "session"}))
hits = json.loads(m.recall("caller:john")) # already pseudonymized
reply = llm(prompt_with(hits)) # model sees tokens only
mapping = json.loads(m.anon_mappings())[0]["mapping"]
final = json.loads(m.rehydrate_text(reply, json.dumps(mapping)))["text"]An egress control you cannot test is close to one you cannot use, and a fixture file is the first artifact an auditor asks for. Write down what the policy must redact and what it must not, and fail the build on either direction:
{
"policy": {
"mode": "egress",
"default_action": "allow",
"categories": { "sg_nric": "redact", "mrn": "redact", "email": "redact" }
},
"must_redact": [
"NRIC S1234567D on file",
"MRN 00456123 admitted",
"contact jane@example.com"
],
"must_not_redact": [
"invoice total 4471820 aed",
"S1234567A is not a valid NRIC",
"the ward saw 12345678 visitors"
]
}areev anonymize test --fixtures policy-fixtures.json
# → 6 fixtures: 6 passed, 0 failed (0 missed, 0 false positive)
# exits non-zero on any miss or false positiveThe must_not_redact half is the load-bearing one: a policy that redacts
everything passes must_redact trivially. Note what the negatives above pin —
S1234567A has a valid NRIC shape but a wrong check digit, and the bare
digit runs are quantities, not identifiers. The national-ID detectors are
checksum-gated (Singapore NRIC/FIN weighted mod-11, UAE Emirates ID Luhn +
784 prefix), and MRN is cue-gated on a nearby MRN / medical record number, because matching bare digit runs would redact every quantity in a
clinical note.
A name alone may be fine in a prompt; a name beside a condition is health data. That is a property of the pair, so no per-category action can express it:
{
"mode": "egress",
"default_action": "allow",
"categories": { "person": "allow", "condition": "allow", "phi": "pseudonym" },
"term_sets": { "condition": ["type 2 diabetes", "hypertension"] },
"co_occurrence": [
{ "when": "person", "near": "condition", "within_chars": 120, "as_category": "phi" }
]
}"Jane Doe called about the invoice" keeps the name; "Jane Doe was diagnosed
with type 2 diabetes" comes back as [PHI_1] — and the placeholder says why
it was escalated rather than reading like an ordinary [PERSON_1].
scope: "session" keeps tokens stable across calls in one process. MCP and
CAL payloads carry an anonymized report with mapping ids only — the
mapping itself never rides a payload; the host process rehydrates.
The free-text APIs (scan_text/anonymize_text) propagate the same
interned-subject table — a name written as a subject under the handle's
namespace is caught in prose you scan/anonymize directly, not only in
grain reads:
m = areev.Areev("support.db", ns="caller")
m.add_fact("Kenneth Shea", "role", "sell-side banker", ns="caller")
m.set_anon_policy("caller", json.dumps({"mode": "egress"}))
out = json.loads(m.anonymize_text("Kenneth Shea sent the Project Falcon NDA.", None, None))
out["text"] # "[PERSON_1] sent the Project Falcon NDA."For identities you hold but never interned as a grain subject — an email's
From header, a CRM row, a project codename — pass them straight in the
policy's known list, each with its own category:
policy = json.dumps({"known": [{"value": "Project Falcon", "category": "custom"}]})
out = json.loads(m.anonymize_text("...Project Falcon...", policy, None))Cross-process reconstruction needs the sealed vault (encrypted memory):
export MEMPASS="correct horse battery staple"
areev anonymize set --db support.db --passphrase-env MEMPASS --ns caller \
--policy '{"mode": "egress", "scope": "session", "vault": true}'
areev recall --db support.db --passphrase-env MEMPASS --ns caller --subject caller:john
# …later, any process — admin-gated, audited by fingerprint:
areev anonymize reveal --db support.db --passphrase-env MEMPASS \
--ns caller --token "[PERSON_1]"Store less instead: {"mode": "ingress"} (encrypted memory required)
transforms before the hash commits — remember's extractor LLM never
sees the raw text, the stored blob holds value-derived tokens, and
forget-subject/subject-report by the real name still work (the pseudonym
is recomputed from the identity).
Roll out and harden:
# Measure first: counts per category, no transform (visible in /api/config)
areev anonymize set --db support.db --ns caller --policy '{"mode": "audit"}'
# Per-category actions + a custom dictionary
areev anonymize set --db support.db --ns caller --policy '{
"mode": "egress",
"categories": {"credit_card": "redact", "secret": "redact",
"phone": "mask", "date": "generalize:month"},
"custom_terms": ["Project Nightingale"],
"because": "prompts leave the boundary via provider X"
}'
# Host floor (any verb): force egress on without a declared policy
areev recall --db support.db --ns caller --subject caller:john --anonymize-egress
# Beyond Tier-0: a local NER command and/or a grounded LLM detector
areev recall --db support.db --ns caller --subject caller:john \
--anonymize-cmd './presidio-bridge.py' --anonymize-llm-cmd 'ollama run llama3'A policy that demands "detectors": ["tier0","ner"] with no backend
installed fails the read closed — never a silent downgrade. In the console,
Connect → Settings has the declare/clear form and any grain's developer
panel has Model view (what the model would see).
Backends. Everything above except the value-derived features works on
both backends (conformance-pinned): on Postgres there is no page cipher,
so ingress, memory scope, and the vault refuse loudly at set — use
egress with context/session scope there, and note session tokens are
per-process (each writer holds its own mapping). Cross-process-stable
tokens and the vault are file-backend + encryption features by design.
Grains stay small; media lives in the per-memory content-addressed store and is
referenced by cas:// URI. The address IS the content, so storing the same
bytes twice stores them once.
# Store bytes, get the address back (idempotent).
URI=$(areev blob put invoice-001.pdf --db acct.db)
echo "$URI" # cas://sha256:a0864a70...
# …or from a pipe.
pdftotext invoice-001.pdf - | areev blob put --stdin --db acct.db
# Read them back, hash-verified.
areev blob get "$URI" --db acct.db > roundtrip.pdfReference the blob from a grain's content_refs so erasure and GC can see it:
a blob referenced by no live grain is reclaimed by gc_blobs, and a
sole-referenced attachment is reclaimed by forget-subject along with the
grain that named it.
blob get deliberately does not open the memory. The embedded backend
takes an exclusive file lock, so while areev run holds a memory every other
verb is refused — including a read. A tool subprocess launched by that run
would otherwise be unable to fetch the very attachment it was started to
process:
# inside a --tool-cmd subprocess, while the run holds the writer:
areev blob get "$attachment_uri" --db acct.db > /tmp/att.pdf # works
areev recall ... --db acct.db # STO-E001, lockedThis is safe rather than a loophole: blobs are immutable, live beside the file
rather than in it, and carry their checksum as their address, which the read
re-verifies. For grains the doors are different: declare a
--context-query on the trigger so the evaluator assembles a saved query's
result into the run input before the run starts, or run the PostgreSQL
backend, where reads never block and a tool may open the memory mid-run
(see run.md). An encrypted memory is the exception — decrypting the sidecar
needs the derived key, so blob get opens it and therefore needs
--passphrase-env and an unheld file.
Both bindings carry the pair, bytes in and bytes out (not the usual JSON-string return — base64 through JSON would inflate every payload by a third):
uri = m.put_blob(open("invoice-001.pdf", "rb").read())
data = m.get_blob(uri) # bytesconst uri = await m.putBlob(await fs.readFile('invoice-001.pdf'))
const data = await m.getBlob(uri) // BufferThere is no MCP tool for blobs, deliberately: MCP results are JSON over stdio,
so a binary payload would have to be base64'd into the response and would blow
the context window it landed in. Tools that need bytes should take the cas://
URI from a grain's content_refs and shell out to areev blob get.
A sandboxed capability tool has its own door (#106): declare
{"blob": {"read": true}} and call areev::blob_get, which reads by content
address through the run's broker and journals a blob_read. That is the path
for anything processing untrusted attachment bytes, since it needs no
subprocess and no filesystem access — see run.md.
A trigger is a standing rule that starts a workflow. The cadence lives in the
memory rather than in someone's crontab, so a synced file can say what it was
supposed to be doing — and there is still no daemon: areev trigger run is
a one-shot command that asks the memory what is due.
Poll a mailbox and start a workflow per message:
areev trigger add --db ap.db --ns accounting \
--type polling --observer outlook \
--scope 'mailbox:accounts@acme.com' --interval 120 \
--workflow <WF_HASH> --dedup-key /message_id \
--because "poll the AP mailbox for invoices"
areev trigger run --db ap.db --ns accounting --dry-run # safe first command
areev trigger run --db ap.db --ns accounting \
--connector-cmd ./outlook.sh --tool-cmd ./tools.sh--dedup-key mints the run id from the item's identity, so a redelivered
webhook, an overlapping cursor, and two nodes racing all produce one run and
one recorded skip. The first poll seeds the cursor and fires nothing, so you
do not process the mailbox's history on day one.
A firing gets the same runner run start builds, so a plan that runs by
hand runs on a heartbeat — the executor pin, the sandbox and the model all
reach it, and each also reads its $AREEV_RUN_* variable, because a heartbeat
is a cron line rather than something you type:
# The cron line: no flags, all host config from the environment.
AREEV_RUN_TOOL_CMD=./tools.sh \
AREEV_RUN_ALLOW_EXECUTOR=1671652297b93a6a… \
AREEV_RUN_SANDBOX_CMD=/usr/local/bin/areev-sandbox \
AREEV_RUN_EXECUTOR_TIMEOUT=900 \
AREEV_RUN_MODEL=claude-sonnet-5 \
areev trigger run --db ap.db --ns accounting --max-usd 0.25 --ask-ttl 3600Budgets matter more here than anywhere else: a standing rule fires unattended,
so an unbounded run has nobody watching it and an ask with no TTL parks
forever. Give the trigger a --name too — it is what trigger list and
trigger status print, and unlike the workflow hash it survives re-declaring
the plan (a re-add of identical plan JSON mints a new grain, because the
content address covers created_at; see triggers.md).
Fire on the memory's own contents instead of an external source:
areev trigger add --db crm.db --ns sales \
--type memory --workflow <WF_HASH> \
--where 'grain_type = "fact" AND relation = "signed_nda"' \
--because "kick off onboarding when an NDA is recorded"Wait for two things to arrive before starting anything — an invoice and its purchase order, correlated by thread and windowed:
areev trigger add --db ap.db --ns accounting \
--type composite --workflow <WF_HASH> \
--members 'invoice=<HASH_A>,purchase_order=<HASH_B>' \
--where 'invoice = true AND purchase_order = true' \
--correlate /thread_id --window 10m \
--because "match an invoice to its purchase order before posting"A gate naming a member the declaration does not carry is refused when you
declare it (TRG-E008), not when it first comes due — a trigger that can never
fire has no symptom except silence.
Webhooks arrive through your own listener; Areev never opens a port:
areev trigger deliver --db crm.db --ns sales --id <TRIGGER> < payload.jsonThen put evaluation on whatever heartbeat you already run. The rendered interval is the GCD of your declared intervals, floored at 60s — deliberately coarser than your shortest trigger, because the memory owns the real cadence:
areev trigger render --db ap.db --ns accounting --target cron # or launchd,
# systemd,
# k8s-cronjob
areev trigger status --db ap.db --ns accounting # what fired, what is duetrigger status is per-host: evaluation state deliberately does not replicate,
so a dev memory restored from prod cannot inherit prod's cursor and silently
skip real work.
In containers the heartbeat is an image command — docker run areev heartbeat --ns accounting loops the same one-shot evaluation on $AREEV_HEARTBEAT_SECS
ticks; compose files and the multi-agent fleet pattern are in
docker.md.
If your host already embeds Areev, skip the binary — every subcommand above is a binding method, so the process holding the memory can fire its own rules:
m = areev.Areev("ap.db", ns="accounting")
report = json.loads(m.trigger_run(connector_cmd="./gmail.sh", tool_cmd="./tools.sh"))Full reference: triggers.md.
An agent that calls a real API needs a real secret, and the usual arrangement — export it and hope — has two problems. It is static, so a cloud token that expires hourly breaks an overnight heartbeat; and it is ambient, so every tool subprocess can read it out of its own environment and skip the broker entirely.
Areev's answer is in two halves, and it matters that they are separate:
| Half | Question | Mechanism |
|---|---|---|
| The broker | where may a secret be sent? | --allow-host, --tool-egress, the tool's capabilities |
| The source | where does a secret come from? | --credential NAME=… |
A vault only improves the second. It does not remove the broker: a perfectly minted 5-minute token handed to a compromised tool is still exfiltrated in under five minutes. Run both.
# 1. static, as before — right for an API key that does not rotate
--credential zoho=ZOHO_TOKEN
# 2. any command that prints a token on stdout — the general form
--credential 'sheets=cmd:gcloud auth print-access-token'
# 3. Vault / OpenBao natively, so a container needs no vault binary
--credential 'sheets=vault:secret/data/google#access_token' \
--resolver-env VAULT_ADDR,VAULT_TOKENForm 2 is the one to reach for first: it needs no vendor client in Areev and it
covers every provider below. Values are cached for --credential-ttl seconds
(default 300) and minted again after, so a revocation upstream takes effect
without restarting anything.
Each row is a cmd: resolver. The auth column is what makes it work with no
secret on disk — that is the property worth optimizing for, and it is what
--resolver-env then has to carry.
| Platform | Store | How the resolver authenticates | Resolver |
|---|---|---|---|
| AWS | Secrets Manager | EC2 instance role / ECS task role | cmd:aws secretsmanager get-secret-value --secret-id sheets --query SecretString --output text |
| GCP | Secret Manager | attached service account / Workload Identity | cmd:gcloud secrets versions access latest --secret=sheets |
| Azure | Key Vault | Managed Identity | cmd:az keyvault secret show --vault-name kv --name sheets --query value -o tsv |
| Self-hosted | Vault or OpenBao | AppRole, KMS auto-unseal | vault:secret/data/sheets#token or cmd:vault kv get -field=token secret/sheets |
| Any | short-lived cloud token | ambient credentials | cmd:gcloud auth print-access-token |
On the three managed services the resolver needs only its platform's ambient
identity, so --resolver-env often names nothing at all — the CLI finds its
config under $HOME, which resolvers already get. Self-hosted Vault is the
case that needs --resolver-env VAULT_ADDR,VAULT_TOKEN.
OpenBao is the MPL-licensed fork of Vault and the one to default to for a new
self-hosted deployment. Beside the compose file in docker.md:
openbao:
image: openbao/openbao:latest
command: ["server", "-config=/bao/config.hcl"]
volumes:
- bao-data:/bao/data
- ./docker/bao.hcl:/bao/config.hcl:ro
heartbeat:
image: areev:latest
command:
["heartbeat", "--db", "/data/agent.db", "--ns", "agent",
"--credential", "gmail=vault:secret/data/google#access_token",
"--resolver-env", "VAULT_ADDR,VAULT_TOKEN"]
environment:
VAULT_ADDR: http://openbao:8200
VAULT_TOKEN_FILE: /run/secrets/bao_token # read it into VAULT_TOKEN at entryUse raft integrated storage (the recommended backend) and auto-unseal against
a cloud KMS; on the PostgreSQL deployment profile a vault can share the
Postgres server in its own database, so you add no new storage system.
Never store secrets in the Areev memory itself. Grains are immutable,
content-addressed and they replicate — areev stream/follow would carry
every secret to every replica, and a supersession cannot actually remove the
old value. A declaration names a credential; it never carries one.
--resolver-envis a carve-out, not a pass-through. The variables it names are withheld from every other subprocess and re-admitted only for resolvers. That is the point: aVAULT_TOKENleft ambient is readable by every--tool-cmdyou run, and unlike the credential it fetches, it can fetch all of them.- A failing resolver refuses the call. It never sends the request unauthenticated, and the error names which credential failed without repeating what the resolver printed. Check for it in the audit trail the way you check for any other refusal:
areev cal 'RECALL observations WHERE namespace = "agent:harness"' --db agent.db
# → observation_kind "egress_refusal", reason "credential 'sheets' could not be resolved"Full rules: run.md and
security-model.md.
../ARCHITECTURE.md— how Areev is builtcal-reference.md— the CAL query languagemcp-reference.md— the MCP toolstriggers.md— the eight trigger kinds, in fullrun.md— the governed workflow runtimedocker.md— the container image: compose, heartbeat, cloud deploysgdpr.md— GDPR obligations → capabilities (for a DPIA)../FAQ.md— concepts and comparisons../SECURITY.md— trust model and hardening