Threat model — agent memory as an attack surface
This whitepaper formalizes what undercroft’s code already implements: the adversaries it defends against, the mechanism that defeats each one, and — with equal precision — what it does not defend against. It is the document a security reviewer should be handed alongside SECURITY.md (the disclosure policy and scope list), the security model (the mechanism reference), and SECURITY_COMPARISON.md (the market context). Nothing here is aspirational: every defensive claim names the shipping mechanism, and planned work is labeled as planned.
1. Why a memory layer needs a threat model at all
Agent memory crossed from convenience to attack surface in the research literature well before most memory products acknowledged it:
- Query-only memory injection — MINJA
(arXiv:2503.03704) demonstrated
95% success poisoning an agent’s memory bank using nothing but ordinary queries: no privileged access, no direct writes. The poisoned records then surface to other users of the shared memory.
- Backdoored memory records — AgentPoison showed optimized records planted in a memory store act as retrieval-triggered backdoors: specific future queries reliably retrieve the malicious record and steer the agent’s behavior.
- Forged reasoning and over-remembering — 2026 work (arXiv:2607.05029, arXiv:2607.06595, arXiv:2601.05504) extends the attack family: forged agent reasoning traces stored as memory, poisoning through content an agent was merely asked to process, and systematic study of defenses. FragFuse (arXiv:2606.15609) uses the memory layer to bypass access control by fragmenting a forbidden query across turns and letting memory fuse the answer.
Two properties make memory attacks worse than prompt attacks: they are persistent (one successful poisoning misleads every future session until discovered) and transitive (a store shared across users or agents spreads the compromise). And the store itself concentrates risk even absent an active attacker: it holds the most sensitive distillate of a user’s life or an organization’s operations, usually — in the current market — as plaintext with no integrity story.
A memory layer therefore has two distinct security jobs:
- Protect what it holds — from disk theft, tampering, cross-tenant bleed, and exfiltration. This is where undercroft’s shipped cryptography lives, and it is the subject of most of this document.
- Be honest about what it was told — preserve exactly what was written, by whom, when, so that poisoning is attributable, auditable, and reversible rather than laundered into anonymous “facts.” This is where verbatim storage is a security property, not a retrieval preference (§6), and where the write-path provenance and admission work (§8) extends the design.
2. System sketch
One machine, local-first, zero external calls by default. Memories are
stored verbatim in per-namespace vaults. Each vault derives its
own encryption/MAC/manifest keys via HKDF-SHA256 from a master key that
never leaves the machine. In a sealed vault (the default), content and
every plaintext-derived artifact — embeddings, PQ code rows and
pages, codebooks, ColBERT token matrices, FDE vectors, and the knowledge
graph’s objects and its subjects, predicates and entity names — are
encrypted with XChaCha20-Poly1305 before touching disk, each under an
AAD that binds the vault id and the artifact’s identity. Those last three
were clear TEXT before 1.0.0 (ROADMAP A10): the columns now hold a
truncated keyed HMAC so SQL equality still works, the words are sealed
beside them, and the graph’s two ids — previously unkeyed SHA-256 digests
of the same words — are keyed as well. Every record
carries an HMAC-SHA256 tag verified before content is returned, and
every write advances a hash-chained audit log inside the same database
transaction as the data. The mechanism reference with diagrams is the
security model; implementation lives in
crates/undercroft-vault (keys, sealing, chain arithmetic, export
bundles) and crates/undercroft-store (transactional chain, verify,
rotation).
3. Adversary classes and what defeats them
Each class states: capability, goal, shipped defense, and residual risk.
A1 — Offline reader (stolen disk, backup, copied volume)
Capability: full read access to the palace directory at rest — every database, manifest, and derived artifact. No keys, no passphrase. Goal: read memories or anything content-derived.
Defense (shipped): a sealed vault yields not one word of the
content, nor of anything derived from it that copies its words.
Content is zstd-then-AEAD; embeddings and all index artifacts are
sealed under their own AAD domains; sealed vaults build no FTS index;
every content fingerprint is keyed — the duplicate-detection one
with the vault mac, and since U12 the two provenance fingerprints
(supersedes_fp, kg_triples.source_fp) with the long-lived stored
kg_secret, so none of them is a confirmation oracle. What a keyed
fingerprint still reveals is EQUALITY between rows, never content;
Drawer::meta_at_rest() strips time_mentions[].text and
entities before a row is written, keeping only offsets and ISO dates.
The at-rest bytes are asserted opaque by tests, and every new derived
artifact is required (project invariant) to follow the same pattern.
Residual — and it is larger than “counts and sizes”. This page said
“record counts and sizes, nothing else” for several releases. That was
false, and the project’s own test
(a_sealed_vault_exposes_metadata_but_never_content) has pinned the
real inventory the whole time. meta_json is stored unsealed, so an
offline reader of a sealed database reads, in the clear:
| Exposed | Why it is there |
|---|---|
| wing name, room name | indexed scope columns; in practice topics, people, case ids |
source_file path | provenance; a filesystem path is often the topic |
added_by | surface stamp |
hall label | taxonomy |
content_date | declared date |
| dates resolved out of the content | resolutions only — offsets + ISO dates, never the words |
declared kind | closed vocabulary, ≤10 bytes, NULL when undeclared (docs/LABELS.md) |
supersedes link (+ supersedes_receipt) | chain topology: which record replaced which. The link is a drawer id (an unkeyed deterministic digest of wing/room/source/chunk, not of content); the receipt is a keyed HMAC |
supersedes_fp, and kg_triples.source_fp | a keyed fingerprint of a superseded / cited document’s verbatim content — CLOSED as ROADMAP U12. Both were an unkeyed SHA-256 in the clear, and this page called them “HMAC-derived hex”, which was wrong twice over. They were a confirmation oracle: an offline reader holding a candidate document hashed it and matched the column, learning byte-exactly that this plaintext was filed here — bounded only by having to reproduce the text, which is weak comfort when a drawer is one line. They are now HMAC(kg_secret, sha256(content)), keyed with the long-lived per-vault secret that rotation re-seals and never regenerates, so they stay rotation-stable without being an oracle. What remains readable is EQUALITY: two rows citing identical content still hold identical bytes, so a reader learns that two receipts point at the same text and never what it says. Legacy vaults are migrated at the next writable open; a row whose receipt does not verify is left alone rather than laundered and is reported on VaultStats.unhealed |
agent / channel / session claims | writer-declared provenance |
filed_at / updated_at | per-row timestamps |
| record counts, per-record ciphertext sizes | unavoidable at this layer |
If a wing name, a room name or a file path would itself be sensitive
in your deployment, do not put the secret in the name. Treat all of
the above as public labels until this is closed. Closing it means a
keyed blind index (truncated HMAC, as fingerprint() already does) for
the fields that need SQL equality, and a sealed blob plus a RAM cache
for the rest — which is exactly what the knowledge graph’s subjects,
predicates and entity names got in 1.0.0 (ROADMAP A10, unit 1 of 3),
and the pattern the remaining two units follow. The blind-index key is
long-lived and separate from the vault’s rotatable keys, which is the
standard searchable-encryption separation and is not optional here: the
graph’s ids are derived from it, and an identifier that moves on key
rotation orphans the audit records that reference it, breaks every receipt
bound to it, and invalidates any id an export or an agent still holds.
Re-keying a blind index also means re-indexing the corpus. So it is a
per-vault secret stored sealed, which rotation re-seals and never
regenerates. Note what that unit had
to include beyond the columns: the graph’s two ids were unkeyed SHA-256
digests of the same words, so they were a confirmation oracle on their own
— blinding only the columns would have closed nothing, and a
literal-substring gate could not have seen it. Ask that question of
anything derived from a field in this table. The test fails in both
directions, so shrinking the exposure forces this table to be updated
rather than quietly over-promising again.
And ask it of the audit table, which is where unit 1’s first attempt
still leaked. Every write records its subject’s id in
audit.record_id in clear, so on a vault written before A10 the audit log
held kg/<unkeyed digest of the words> — the same oracle, one table over,
surviving a migration that had rewritten and VACUUMed every column it
knew about. The migration now carries each moved id’s audit label with its
row; that was sound when it was written because the chain hashed
audit.tag and nothing else, so record_id read as a navigation label
rather than evidence, and leaving it behind orphaned the audit trail as well
as leaking. ROADMAP O233 refuted that reading in 1.6.0: the trust floor’s
policy comparison, the orphan-label leg and a forget attestation’s recorded
run all decide from labels, so a label is evidence and the chain step now
folds it. The walk still runs, on a vault whose chain has not switched yet,
and the switch waits for it; on a switched chain no migration may rewrite a
label in place. For the two units still
open this matters directly: audit.record_id holds wing and room names
in clear today (trust/{wing}, retention/{wing}[/{room}]), so treat
them as part of the same exposure and not as a separate question.
Also residual: at-rest sizes correlate weakly with content
compressibility (standard compress-then-encrypt caveat; bounded because
every drawer is compressed in its own frame with no shared dictionary —
see the DBREACH note under the project invariants). Vaults created as
hmac-only store plaintext by explicit operator choice — the level
exists for grep-ability and is labeled, not a default.
A2 — Offline tamperer (modify, truncate, or roll back the store)
Capability: read–write access to database and manifest at rest.
Goal: alter a memory, forge a record, delete evidence, or roll the
vault back to an earlier state without detection.
Whether this attacker holds the key (stated 2026-09-24, ROADMAP O247 — the
paragraphs below assumed both answers in different places): on a deployment
that keeps master.key in a file beside the database, write access to the
data directory usually reads the key too, and this attacker is then a
KEY-HOLDER, who can rotate and can forge a record the chain accepts; only an
out-of-band record helps against that. On a passphrase deployment
(UNDERCROFT_PASSPHRASE, where only kdf.salt is on disk) or with the key
on other storage, it is not — and the in-band checks (the record HMACs, the
chain replay, the label guard) are what stand against it.
Defense (shipped): tamper is detected on read, not merely
resisted. Any record, KG triple, or tunnel that fails its HMAC
surfaces immediately — a read returns an integrity error, never partial
data. undercroft verify audits everything. What a tag covers is a run
of bytes, and since 1.7.0 that run has one reading (ROADMAP O406): every
row tag is over a table-domained, length-prefixed canonical under its own
HKDF subkey, so a writer WITHOUT the key can no longer move a field
boundary inside a row, or carry one table’s row and tag onto another’s,
under a tag that still verifies — both were possible over the
separator-joined version-1 canonicals, and a drawer forged from a fact an
agent wrote was served by get and wake_up under VERIFY OK. A vault
moves to the new format once, at its first writable open, after verify
passes every leg the re-tag would bind; until then verify’s identity and
shape legs name the moves the old format admits, and the walk withholds. The audit chain advances
transactionally with each write (chain_meta + chain_append in the
same SQLite transaction), and the manifest holds a lagging, MAC’d
rollback anchor reconciled at every open: an anchor behind the
database head replays as a crash and heals silently; an anchor that is
not in the replayed chain at all is a rollback alarm
(ManifestTampered). Durability is pinned so the alarm cannot
false-fire: WAL + synchronous=FULL guarantee data+chain reach disk
before the anchor can, so power loss lands in the healed case by
construction. Deletions write keyed tombstones — absence is also
evidence. That guarantee held for ONE writer until 1.7.0: two writable
handles on one vault anchored through one shared temporary file, so one
could truncate the file the other was renaming into place. Since 1.7.0 one
door writes the manifest, after its commit and under the database’s write
lock, through a random-nonce temporary file (ROADMAP O254). (This paragraph
still called that a known defect after O254 closed; corrected with O253.)
And a concurrent writer no longer makes the label guard, verify or an
open report a broken chain that is not broken: every judgement reads what it
compares from one database state, the manifest anchor before that state is
pinned (ROADMAP O253). And a writable open no longer writes a staged key
rotation’s manifest over a vault.json that changed after its unlock read it:
its promote wrote the staged manifest from memory over whatever vault.json had
become, so a database rolled back beneath it with vault.json deleted opened
as a crash heal with verify clean, and a forged vault.json was overwritten
with no tamper signal. Since 1.7.0 it promotes only while vault.json.next and
vault.json are still what its unlock read, or skips the write when a promote
has already happened, and otherwise refuses in the class any live handle gives,
writing nothing (ROADMAP O290). And no writable open removes vault.json.next,
writes the staged manifest or seeds the key-generation marker before it has
judged that the database answers to the manifest it will answer to: the audit
chain is replayed, under the keys the open will hold, against that manifest’s
anchor, first. A marker deleted or planted during a deferred rotation, a
pre-rotation database restored beside the staged file, or one carrying the new
generation’s marker copied from the live database each made the open delete the
only copy of the new salt, or write the new manifest over the only one the
database answers to, and only then refuse (ROADMAP O296). Residual: every
value that judgement reads is in clear rows an offline writer can copy or
truncate — the rotation preserves audit tags and re-steps only the heads — so a
database made to replay under the other generation’s keys passes it, the open
removes or promotes, and verify and the first read then fail: the effect of
deleting the salt file, which the same writer can do directly. Only a walk of
every re-keyed row could tell them apart, which an open does not make. A
pre-rotation database restored exactly at the manifest’s anchor beside the staged
file is, to every value there is, a rotation that crashed before its commit: the
stage is discarded and the vault opens at the older state (A2’s pair restore,
the witness’s). And the licence for a promote is read one replay before the
promote writes, so the promote asks the manifest rule AGAIN before it writes
(ROADMAP O304), and that ask can only refuse or withhold the write: an offline
edit of vault.json or vault.json.next landing in that window is answered as
it would be at the licence — a forged file is the tamper verdict and is kept, a
deleted one or a lost .next is refused with nothing written — and a manifest
of the new key generation at a head nothing judged is reopened and judged. What
remains is the second ask’s distance to the write’s rename, about one fsync and
not growing with the corpus, where an edit is still overwritten by the staged
manifest the database answers to, with no tamper signal.
And no open vouches for audit rows nothing replayed: a database whose
chain_meta held no committed head was adopted by seeding the MANIFEST’s head
and height, so a rollback with chain_meta and the version-2 commitment deleted
opened, took writes, and its first write moved the anchor over the evidence of
the rollback. Since 1.7.0 an open adopts only a fresh vault’s EMPTY chain — no
record, no height, the anchor at genesis — seeded with those constants under the
write lock; records or a height with no head, and a head with no height, are
integrity findings, and an erased trail beside a later anchor is the rollback
alarm (ROADMAP O303). Seeding the replayed head instead was measured worse: the
store computed the keyed head over rows a writer without the key had appended,
and the version-2 switch bound their labels. A database written by a source
build of this repository older than 0.19.0, and never opened by a 1.x build, is
refused with them — 1.0.0 declared nothing before it needs to open.
Residual (documented): an attacker with full disk control who
restores a consistent old database + manifest pair together rewinds
the vault to a state that was genuine at the time; the chain cannot
distinguish that from the machine having been off — during a deferred key
rotation that pair is three files, the retired vault.json and the staged
vault.json.next beside the older database, and every open accepts it with a
lag note (ROADMAP O290 pins it). The mitigation is an
external witness — a record of the chain kept where this attacker cannot
write — filed as ROADMAP O245 and ruled 2026-09-23, with two facts
that decide what such a witness must be. It cannot be the chain HEAD
alone: a key rotation re-derives the keys both chain steps fold under, so
every historical head moves, and this attacker holds the key and can
rotate — a head-only witness reports “superseded” on exactly the rollback
it exists to catch. A sound witness carries the row count and an unkeyed
digest over the audit rows’ preserved bytes, and since 1.7.0 the engine
emits and checks exactly that: undercroft witness emit / check (always
read-only) and GET/POST /v1/vaults/{id}/witness, forwarded on the
orchestrator’s operator plane and, by the maintainer’s ruling, not offered
over MCP. It closes the REWIND direction only: a rollback or erasure at or
below the witnessed height is reported (exit 2, 409 class: "integrity"),
anything appended above it is writes since the witness. What remains is
the operator’s procedure — a cadence, a store the attacker cannot write,
compare before emit — and that is stated in the runbook, not hidden.
Sharpened 2026-09-21 by O241’s ruling, because “together” was narrower
than the truth. The manifest alone suffices to lower the anchor: a
lagging anchor is read as a crash artifact and fast-forwarded, so restoring
a genuine older vault.json beside a CURRENT database is healed silently —
and lowering the anchor first admits a later database rollback to any
point at or above it. So the detector degrades in two cheap steps rather
than one coordinated one, and the pair need never be consistent. The
attacker need capture nothing to do it: backup create keeps up to ten
genuine, validly-MAC’d (vault.db, vault.json) pairs on the same disk —
since 1.7.0 each one exactly the state its verify judged, beside the very
manifest those rows were verified against (ROADMAP O256) — and a manifest is
rejected only for a foreign vault id. Two consequences follow and are filed rather
than absorbed: the writable open used to consume the one observable of this
in silence while a read-only open reported it — since 1.7.0 (O246) it
reports the heal it performed, and how far behind the anchor was, on
unhealed — and an authenticated
statement placed IN the manifest — a key census or a regime marker — is
restored along with it, which is why both were refused (O240, O241).
A3 — Cross-tenant adversary (one vault against another)
Capability: legitimate access to vault A on a multi-vault host — including, in the worst case, the ability to move raw blobs between vault directories. Goal: read or influence vault B.
Defense (shipped): isolation is cryptographic, not logical.
Vault keys are independent HKDF derivations; AAD binds the vault id
into every ciphertext, so a blob copied from vault A into vault B
fails to decrypt — it is not filtered out by a query predicate that
could have a bug, it is rejected by the cipher. Vault, wing, and room
names pass a path-traversal guard (validate_name) — at the write choke
point, and at the admission screen in front of it, because a diversion
rewrites the very fields the guard reads: it moves the declared wing into
intended_wing and puts the reserved quarantine constant in its place, so
until 2026-08-13 an invalidly-declared write whose content tripped the
detector was quarantined rather than refused (ROADMAP O30). The other
declaration checks stayed behind that rewrite until 2026-09-16 (ROADMAP O170).
That included the drawer id’s shape, and the id is an AAD component, so the
shape check is part of the cross-artifact separation above. It also included
a caller’s vector, filed_at and supersession. So a flagged record with a
malformed id was quarantined, and a refusal quoted the review-queue id, which
told the caller the screen’s verdict. All of those checks now run at the screen
as well. A guard at the choke point is necessary and was not sufficient. This is the
property that makes vault-per-customer multi-tenancy defensible; every
competitor surveyed in SECURITY_COMPARISON.md
isolates tenants with a metadata filter.
A4 — Network adversary (reaching the served surface)
Capability: network access to a served palace (HTTP /v1, MCP,
orchestrator /t/*).
Goal: read or write vaults without authorization.
Defense (shipped): two independent layers. A palace-wide bearer is
mandatory for any non-loopback bind and gates every authenticated
route. Optionally (and always, in multi-tenant deployments), every
/v1 request must additionally carry a per-vault assertion:
HMAC-SHA256(secret, "<ts>|<vault_id>") with the vault id inside the
MAC — an assertion for vault A cannot address vault B, timestamps
outside ±120 s are refused, comparison is constant-time, and failures
return a bare 401 with the reason only logged server-side (a detailed
error would leak vault existence or forgery proximity). The
orchestrator stores tenant tokens as HMACs and seals engine
credentials; token rotation invalidates the old token fleet-wide on the
next request.
--read-only is a posture on the whole process, not a filter on
one port. Both stores serve-http opens — the /mcp handle and every
/v1 tenant vault — are opened read-only, so no embedder migration
runs and read auditing is force-disabled with a warning rather than
silently. The REST gate sits in front of dispatch, not at the top
of each mutating handler, and it fails closed: every non-GET is
refused unless it is on a four-entry allowlist (POST …/search,
POST …/verify-forgetting — a caller-supplied attestation that has to
travel in a body — and POST …/verify — which checks the database file
itself with SQLite’s full integrity_check, walks every record’s HMAC,
replays the whole
audit chain, checks every supersession receipt, checks every
knowledge-graph fact receipt, resolves every graph and drawer audit
label, compares four of the five mirror columns (wing, room, kind, supersedes; filed_at is deliberately excluded — the column takes the write path’s own clock while the covered field was stamped at construction, so they differ by a clock read in normal operation and checking it reported healthy vaults as tampered) against the covered meta, checks every wing-trust and retention row against the chain record that assigned it in both directions — a row that does not verify or was never recorded, and a recorded assignment whose row is gone with no later clear — and matches every drawer, fact, entity and tunnel row against the chain record that last wrote it, so an older version written back offline, or a row present after its recorded destruction, is a finding, and re-derives every fact’s id from its words and names one id in two tables or a covered field of a shape no write produces (twelve legs since O406 added identity and field shapes — the moves a separator-joined row canonical admitted under a tag that still verified; ten from O269, both in 1.7.0, when O269 added the storage leg — an
index page flipped offline passed every row leg while hiding rows from every read
that finds one through it; nine
from 1.6.0, when O233 added the label commitment and O234 the version check; seven from 1.3.0, when O94
added declared-policy drift; six from 1.1.0; five
from 2026-08-06 — the fact-receipt leg arrived in 1.1.0, and until it did, a forged citation answered VERIFY OK on every surface while
backup create archived it as clean), and is a POST for cost, not for effect: it takes &self
and writes nothing at all). Said plainly, because an earlier draft of
this page said the opposite: verify does not fast-forward the
manifest anchor. anchor_manifest needs &mut; the fast-forward
belongs to init_chain and only a store open reaches it. So a
long-lived server cannot tighten a lagging anchor by calling verify —
store_for caches the handle and never re-opens (ROADMAP A31). MCP
refuses every tool that is not on its READ list — it fails closed the same
way, so a tool added later is refused until someone classifies it as a
read — and a test counts the read and write lists against the advertised
tool inventory, so a tool in neither list fails the build. The shape
changed because the per-handler
version had thirteen guards for fourteen mutating routes: POST …/kg/authority was simply never given one, so a --read-only server
rewrote HMAC-covered authority columns, superseded the previous
canonical holder and appended to the audit chain while answering 200 —
while the identical capability over /mcp in the same process answered
“server is read-only”. One forgotten call is a silent write door, so
the decision moved to the one place every request passes through.
--read-only bounds the open as well as the request surface, since
1.0.0 (ROADMAP R4; this paragraph used to record the opposite). The
connection is SQLITE_OPEN_READ_ONLY under PRAGMA query_only=ON — so a
write that was missed fails loudly instead of happening quietly — and the
schema is checked rather than created, a lagging manifest anchor is reported
rather than fast-forwarded, an interrupted rotation is honoured in memory
with its vault.json.next left in place — and, since 1.7.0 (ROADMAP O266),
verified against that staged manifest while the disk still shows the
interrupted state: from 1.1.0 the open had MAC-checked the retired
vault.json under the staged keys and refused as tampering, and a lost or
edited .next beside the retired file is now the integrity verdict a fresh
open gives, with no tamper signal — while any OTHER vault.json failing the
staged keys’ MAC, a forgery after a promote beneath the open included, is the
tamper verdict with its signal, as on every handle, and a vault.json that is
missing or not a manifest file is the integrity verdict on every live handle
rather than a fall-back to VERIFY OK (ROADMAP O289, O277); since ROADMAP O288 the unlock reads
.next before vault.json, and the open re-checks the rotation it is about
to report against the files after its database door, so a promote, a discard
or a restore between the unlock and the open is refused with the reopen class
and retried rather than reported as a deferral the vault no longer holds (an
anchor and a deferred rotation between the old order’s two reads had answered
a false ManifestTampered) — and a prefilter loads an index but
never builds one. That last operation is the one the incident runbook’s own
“freeze writes” step used to perform: a read-only open could delete a
writer’s staging manifest (A32). What the open declined to repair is warned
and then readable as unhealed on every stats surface; since 1.7.0 (ROADMAP
O246) a writable open reports there the anchor heal it made, too.
Residual: TLS termination is deliberately delegated to the
operator’s proxy (documented deployment guidance); the engine does not
ship its own certificate machinery. And a read-only connection still
materialises SQLite’s WAL scaffolding — the -shm wal-index and a
zero-length -wal — where the directory is writable. Neither carries
database content and both are reconstructible; where the directory is not
writable the open escalates to immutable=1 and says so, which is what
makes a write-protected mount or a snapshot readable at all.
A5 — Untrusted accelerator (remote vector indexes)
Capability: full control of an attached remote index (Qdrant/Chroma/pgvector/Milvus/Weaviate) — read everything it holds, return arbitrary results. Goal: read content, or corrupt retrieval.
Defense (shipped): remote backends are treated as untrusted
accelerators by design. They receive sealed content bytes and
embeddings only; every candidate they return is decrypted and
HMAC-re-verified locally before use, so a malicious index can skew
which verified records surface (availability/ranking) but can never
forge content. Since 2026-08-04 the remote path also applies the
same retrieval policy as the local one, from the same function
(resolve_search_policy): closed-vocabulary validation of kind and
min_trust, the effective trust floor, and the quarantine fence — each
decided per candidate off the HMAC-verified meta.wing, never off
the wing payload the backend stored. They were absent here until then,
so an index push turned --backend qdrant into a route around
admission control; the fix is one shared required step rather than a
second copy that can drift again.
Residual (documented, opt-in): the embeddings pushed to a remote
index are plaintext vectors — embedding-inversion recovery of
approximate content is a real research capability, which is why remote
indexes are off by default and the trade-off is stated where the
feature is documented. And the shared policy bounds less here than
locally: the backend trait filters on one wing and nothing else, so the
floor bounds what came back, not what was generated, and an excluded
wing’s rows can still spend the candidate budget. That is an
availability cost, never an integrity one — excluded content cannot be
returned or scored. index push also still mirrors quarantined rows,
deliberately: an untrusted mirror can offer any id, so a push-side
filter would not be a boundary, and dropping them would empty the
reviewer’s own scope.
A6 — Exfiltration channels (telemetry, phone-home, models)
Capability: observe everything the process emits. Goal: learn memory content from side channels.
Defense (shipped): the default build has zero telemetry
dependencies and emits nothing. Observability is a compile-time
opt-in (--features telemetry), and when enabled, signals are
metadata/counts only — never drawer content, never keys — and nothing
leaves the process unless an endpoint is explicitly configured. The
default embedder is deterministic and offline; no model runtime, no
external API, no download at first run. What you did not ship cannot
leak.
A7 — Memory poisoner (writing through legitimate channels)
Capability: cause content of their choosing to be written — a malicious document the agent was asked to summarize, a crafted user message, a compromised upstream tool (the MINJA/AgentPoison scenario). Goal: plant records that mislead future sessions, backdoor retrieval, or launder false facts into trusted memory.
Defense (shipped, structural): undercroft narrows the poisoning blast radius in three ways that extraction-based memories structurally cannot:
- Nothing is laundered. Extraction pipelines pass every write through an LLM that distills it into anonymous “facts” — after poisoning, the store contains a confident falsehood with no visible origin. Undercroft stores the exact words: a poisoned record is the attacker’s own text, retrievable as what it is, with its source, wing/room placement, and write time intact.
- Attribution is cryptographic. The audit chain fixes when every record entered and in what order, tamper-evidently. Post-incident forensics (“what did the compromised connector write between Tuesday and Thursday?”) is a query, not an archaeology project.
- Excision is clean and provable. Verbatim records mean a poisoning cleanup deletes the poison — identifiable by source and time — rather than attempting to un-launder distilled facts that already contaminated summaries. Deletions leave keyed tombstones in the chain.
Residual (honest, updated as C3.3 shipped — 2026-08-03/04): the
write path is now screened (deterministic detector + quarantine wing +
chain-audited rulings, opt-in via UNDERCROFT_ADMISSION) at the single
write choke point rather than at call sites, so no surface can reach
storage unscreened; writes carry provenance claims, wings carry
operator-assigned trust classes consumed as a retrieval floor, updates
are screened on the updating surface, the global training draws are
capped per wing and per agent claim, and non-finite external vectors
are refused. What remains true: detection
is heuristic, so a poison written without any of the marker classes
passes the screen, and a record that passes can still be retrieved and
shown to the agent — with provenance, but shown. Against
retrieval-rank manipulation (AgentPoison-style optimization against
the embedder) the specific defenses are structural — per-item scoring,
normalized training vectors, capped draws — not detection of the
optimized content itself. What the design refuses to do is pretend the
problem away by distilling — the literature’s core finding is that
the write path is an attack surface, and a write path that rewrites
content with an LLM adds an attack surface inside the defense.
A8 — Process and host adversary (non-goal)
An attacker who can read process memory while a vault is unlocked, or who controls the host OS, is outside the threat model — stated plainly in SECURITY.md. No at-rest design defends against a compromised kernel; claiming otherwise would be theater. The mitigations that matter at that layer (OS hardening, disk encryption, enclave execution) compose with undercroft but are not provided by it.
4. Layer map — mechanism → adversaries
| Layer (shipped) | Mechanism | Defeats |
|---|---|---|
| Sealing | XChaCha20-Poly1305, AAD = vault id + record/artifact id; zstd-then-encrypt | A1 read, A3 cross-vault replay |
| Key hierarchy | master key (file 0600 or Argon2id) → HKDF-SHA256 per-vault enc/mac/manifest; zeroize-on-drop; key material created only where nothing refers to a key, never under read-only, and a declaration the key files contradict refused before derivation (O204); a passphrase’s key derived once per command and held in memory only across that command’s opens, against a passphrase digest and the salt it came from, the key files classified on every open all the same (O211) | A1, A2, A3; limits blast radius of any single-vault compromise, and an offline writer planting or deleting a key file gets a refusal, not a new key |
| Derived-artifact sealing | embeddings, PQ rows/pages, codebooks, token matrices, FDE, KG under distinct AAD domains; no FTS for sealed vaults | A1 (no plaintext-derived leak path) |
| Record integrity | HMAC-SHA256 per record, verified before every return | A2 forgery, A5 result forgery |
| Audit chain | hash chain advanced in the data transaction; MAC’d manifest anchor; open-time reconciliation (crash ≠ rollback) | A2 rollback/truncation, A7 forensics |
| Durability pinning | WAL + synchronous=FULL; fsync’d atomic manifest rename; key files fsync’d under a temp name and published by a hard link that never replaces one (in place, with a warning, where a filesystem has no hard links — O210) | keeps A2 detection sound under power loss, and a crash inside a key write never leaves a short key under its name |
| Key rotation | one-transaction byte-exact reseal of every artifact + re-tag of every HMAC’d table + chain re-key; two-phase manifest swap, crash-safe; the rotation appends its own chain record, and refuses a vault whose tags, chain, receipts or policy rows verify fails, rather than re-key tampering into authentic data (O232). It holds the vault alone (ROADMAP O257): an exclusive lock on the rotating store’s own connection, from before its checks until its new manifest is written, refuses the rotation — changing nothing — while any other connection has the vault open, in another process or this one; every audited write checks inside its own transaction that the database’s key-generation marker is still the handle’s, so a handle whose keys are not the vault’s commits nothing; an open that meets another generation’s marker refuses as a race (reopen) or an integrity verdict, or re-seeds it only when the audit chain replays under its keys; no unlock deletes a staged manifest. Residual: the fence is only as good as the filesystem’s locks — on NFS, SMB, or a Windows host and a Docker Desktop container sharing a bind mount it can grant beside a live process, where the write door is the backstop; an immutable=1 reader holds no lock and is invisible to it; a rotation by an older build is not fenced | key-compromise recovery; A1 going forward |
| Export bundles | hybrid X25519 + ML-KEM-768 ephemeral-static → HKDF → XChaCha20-Poly1305; header + KEM ct as AAD (v2; legacy X25519 v1 still opens) | A1 for backups in transit/at rest, incl. harvest-now-decrypt-later |
| Server auth | bearer + per-vault HMAC assertion (vault id in the MAC, constant-time, bare 401s); --read-only decided once in front of dispatch, failing closed | A4 |
| Write-path admission | deterministic tier-1 screen at the one write choke point (a required Screen argument every caller must state); flagged writes diverted to the retrieval-excluded quarantine wing; allow/deny chain-audited | A7 ingest |
| Retrieval policy | trust floor + quarantine fence + closed-vocabulary validation resolved before candidates are drawn, and shared verbatim by the remote path | A5 result steering, A7 reach |
| Read/egress audit | egress/export on every export, egress/index-push on every remote-index mirror (a whole-corpus egress, and on an hmac-only vault its payload is the plaintext) and egress/refine on every LLM distillation run that sent anything, dry runs included — destination host with credentials stripped, model, scope and how many drawers’ plaintext was POSTed, recorded on the error path too (O79/O95); egress/embed/repair when a repair re-embeds stored drawers through a served embedder, and egress/advise/dedup when dedup shows stored survivors to the tier-2 advisor, while remote search, admission allow and dedup reuse stored vectors and send the embedder nothing (O167) — none behind a declaration (a read-only handle that serves one warns that it went unaudited; an index push, and a forget --backend, are refused on a read-only handle before anything reaches the mirror — O175 — and repair, admission allow and dedup --apply before their first egress — O167); UNDERCROFT_READ_AUDIT=chain records each content-returning read — search, get, recent, the lists and the KG readers (O50/O51) — with a keyed subject fingerprint, never text | A7 forensics; insider/exfil accounting |
| Remote-index posture | sealed bytes out, local re-verification in; feature off by default | A5 |
| Zero-telemetry default | no telemetry deps compiled in; metadata-only when opted in | A6 |
| Verbatim + tombstones | exact words, keyed deletion markers, chain ordering | A7 attribution/excision |
5. What verify proves
undercroft verify — the CLI, the undercroft_verify MCP tool,
POST /v1/vaults/{id}/verify, the engine’s admin console at /ui, and
the orchestrator’s ops <tenant> verify pass-through, all rendering one
eight-leg verdict — checks the audit labels a chain held when it switched
to the labelled step against the commitment that bound them (ROADMAP
O233); re-checks every drawer record HMAC and every KG and
tunnel tag, every receipted supersession link and every knowledge-graph
fact receipt; resolves every graph and drawer audit label to a live
record (or, for a destroyed drawer, its tombstone); compares the wing,
room, kind and supersedes mirror columns against the HMAC-covered
meta they copy; checks every wing-trust and retention row against the
chain record that assigned it, in both directions; and replays the audit
chain twice over: the
audit rows must reproduce exactly the head committed in chain_meta,
and the manifest anchor must appear somewhere in that replay — equal in
steady state, strictly behind after a crash-before-anchor (legal), and
absent only when the database was rolled back or forked relative to an
anchor it never produced. A clean verify is a machine-checked
statement: every byte this vault will ever return is exactly what was
written, in the order recorded, under the keys it claims. On telemetry
builds the same real signals — never synthetic — drive the
undercroft_hmac_verify_failures_total metric, the live event stream,
and the PalaceTamperDetected alert with its published runbook.
Stated precisely, because the boundary matters to a reviewer: verify
walks the evidence, not the derived index tier. A sealed PQ page is
one AEAD unit carrying its own row-count commitment, so that commitment
is authenticated when the page is opened at search time and again when
rotation reseals it — not by verify. That asymmetry is deliberate:
index artifacts are recomputable from content, so a failure there costs
a rebuild, while a failure in the walked set costs evidence.
The verdict reaches the exit status whatever happens to the output
(1.7.0, ROADMAP O287). verify prints its legs and only then decides,
and so do witness check, verify-forgetting, a retention sweep and the
orchestrator’s ops. Until 1.7.0 a reader that left early — verify | head, a log shipper that died, a harness that truncates — made the print
panic: exit 101, a status no document defines, and the verdict never
reached the exit code. Every stdout write the two binaries’ own code makes
now goes through one door that records the reader leaving and lets the
command finish (a telemetry build’s UNDERCROFT_LOG_FORMAT=json log lines
are the one write that does not — ROADMAP O333), and
the exit status is folded in one order: an integrity verdict 2, then a
failed run 1, then the reader leaving 141, then 0. So a truncating
consumer cannot turn a tamper verdict into a pass: the obvious repair, a
quiet exit 0 on a broken pipe, would have done exactly that, and was
refused for it. A diagnostic printed beside a verdict cannot cost it either:
every diagnostic is a best-effort line since ROADMAP O328, where a warning
from one unreadable tuning knob, on a closed stderr, turned a tampered
vault’s 2 into 101. What no exit code can carry stays the caller’s: a consumer
that reads only the first lines of verify without reading its status can
miss a verdict printed last, and exit 0 is no proof of delivery — output that
fits in a pipe’s buffer is accepted in full before a reader that dies later
can refuse it.
Rotation must re-key every tag, and that is now enforced rather than
reviewed. A tag column is by definition keyed with the vault MAC, which
rotation replaces — so a tagged table with no sweep in the rotation path
does not merely go stale, it starts reporting a FALSE tamper verdict on
every read. That happened to wing_trust and retention_policy, which
carried tags verified on read and were swept by nothing until 2026-08-06:
a routine key rotation broke wing-trust assignment and retention
enforcement permanently, and the trust floor with them. Two gates hold the
line: a source-level inventory requiring every at-rest AAD domain and every
tag-carrying table to be named in the rotation path (with audit as the
one justified exemption, since its tags are preserved verbatim as
historical evidence), and a post-rotation arm that calls every reader whose
contract is “tag-verified on the way out” and requires it to answer
cleanly. The second exists because the first cannot see the failure: a row
whose tag was not re-keyed is byte-identical and simply stops verifying.
And rotation must never re-key a tag it has not checked (ROADMAP O232).
Re-keying recomputes each tag from the row’s CURRENT columns and re-folds the
chain over the audit table as found, so until 1.6.0 anything an offline writer
had changed — a flipped trust class, an edited drawer, a deleted audit row, an
hmac-only vault’s content — came out of a routine rotation validly tagged,
with verify green and the evidence destroyed. A rotation now runs one
verify inside its own transaction and refuses (an integrity verdict) on a
tag that fails, a broken chain, a tampered receipt or a policy finding —
and, since 1.7.0, on a storage finding or a row it cannot read (ROADMAP
O269: a re-key finds each row through the table’s indexes, so an index
missing an entry left that row under the retired key, and a replay an index
hid came out authentic); mirror drift and orphan labels, which it does not
rewrite, do not block it.
Residual, stated: whatever was tampered when a rotation by an earlier
binary ran is authentic now, because the old key was the only witness.
A policy row must also be its key’s newest assignment (ROADMAP O230). An
older wing_trust or retention_policy row written back offline verifies
under the key, so the policy leg compares a row’s tag with its newest chain
record’s whenever that record is newer than the last rotation, and the trust
floor, the sweep and the listings refuse a row that fails the comparison, as
they refuse a flipped one. A row whose newest record predates the last
rotation is not compared. The lookups find records by record_id, which the
chain did not hash until 1.6.0, so an offline writer who also relabelled an
audit row hid a replay; the labelled chain step (ROADMAP O233) folds each
record’s label and time, so that relabel now breaks the chain replay, or,
for a row written before the vault switched, the label commitment. And
since ROADMAP O237 those readers no longer act first: the trust floor, the
sweep, the listings, the forgetting path and the version check all go
through one door that requires the chain to replay under this handle’s own
keys, once per handle on its first such read, and that holds a per-key
append-only invariant over every label it looks at on every one of them. A
chain that does not replay refuses them as an integrity verdict naming
undercroft verify. Two things it does NOT do, and both are deliberate: a
version-1 chain — any vault this binary has not opened writable, a
--read-only server included — never refuses on unbound labels, because
refusing would stop every pre-1.6.0 vault; and the forget attestation’s
mirror disclosure never refuses, because that would trade the erasure
promise for availability, so its meta marker is HMAC-covered instead.
What remains is an APPEND: only the MAC key separates a forged appended
record from a real one, so one written beneath SQLite is invisible to a
handle that has already replayed until it re-opens. ROADMAP O241 ruled
against closing it with an authenticated census in the manifest — a census
catches a key that VANISHES and never one that APPEARS, and the manifest is
restorable from the vault’s own backups — so the mechanism that would close
it is an out-of-band witness (O245).
Corrected 2026-09-24 by ROADMAP O247’s ruling, beside the paragraph above
rather than in place of it. Three of its claims were wrong. The per-key
invariant does not run on the returning read: the version check finds a
drawer’s records in its own query and pins none of them, so the invariant
covers the policy readers, the forgetting path and the rotation boundary.
What remains is wider than an append: between replays (the change cookie
does not move for a writer beneath SQLite) a writer without the key
re-points a label’s newest record by copying an older record forward,
relabelling a later one in place, or deleting the newest record of a label
the handle has not looked up — most of which move no height — and then a
replayed retention policy governs, the sweep destroys a drawer the declared
policy keeps while reporting success, and a returning read serves a replayed
drawer. Measured, pinned as a known cost, and filed as ROADMAP O252. And an
out-of-band witness does not close it: a witness commits to a prefix of the
rows, so anything appended above it is writes since the witness (O245’s
ruling). The honest statement of the defence is narrower: this guard
protects against a writer who does NOT hold the vault key (see A2’s
capability line), and against such a writer in-band answers exist — the live
handle holds what that writer lacks — which is O252’s to rule. A legitimate
CONCURRENT writer made the guard refuse and verify report a broken chain
until 1.7.0, because the replay and the committed head were read in two
snapshots; every judgement now reads them in one, the anchor before it
(ROADMAP O253). And the change cookie is comparable only within ONE
connection: a handle that replaced its connection — the release fallback
after a key rotation’s fence, reached by a library caller and by no product
surface — served the verdict it had cached on the old one, so a commit
another connection made before the swap went unexamined. A replaced
connection now forgets it (ROADMAP O276) — and since O278 the fallback does
not reattach at all: it closes the connection and the handle answers every
door with the reopen class. A reopen by path could not tell its own file from
one a backup restore swapped in — a connection opened before a directory
swap reads the file set aside and writes into the restored vault’s -wal,
measured — and a manifest read under keys another process rotated since would
have raised a false tamper verdict. The same swap race for an ORDINARY open is
closed by ROADMAP O279: a connection takes its file at the open and its first
lock at its first statement, invisible to every fence in between, so an open
that raced a backup restore held the database the restore set aside while its
-wal, -shm and manifest reads named the restored vault — measured, it served
the old vault, healed the restored manifest forward (the restored vault then
refused as tampered), and one save through it corrupted the restored database.
Every open of a vault’s database now proves, after its first lock, that it holds
the file at its path — SQLite’s moved-file check on the connection’s own
descriptor AND the path SQLite resolved naming the file the open’s path names —
and a moved file is closed and refused with the reopen class, which each surface
retries once. No such open creates a database any more unless the directory
holds none (O280: two upgrade-day opens racing the legacy palace.db rename could
empty a vault). Residuals, stated: an immutable=1 read-only handle holds no
lock, so a swap after its open goes unseen for its life (O285); a racer and a
handle of the restored vault in ONE process share a -shm whose locks the racer
can disturb inside its first statement, which no product surface does; Windows
makes no moved-file check (O275). The legacy palace.db rename takes an
exclusive hold first and keeps the old name beside any other connection (O281):
it used to rename beneath an idle holder, leaving a read-only replica on stale
rows with a false verify failure and a writable holder’s later commits lost.
The chain also carries what left and what was read. Every export
appends an egress/export record binding the surface, the recipient
(when the export names one), the record counts and the export’s own
manifest digest. Every remote-index push appends egress/index-push,
binding the backend, the collection, the pushed count, the embedder, what
actually left (sealed bytes, or the plaintext of an hmac-only vault) and
what the operator declared. And every refine run that sent anything
appends one egress/refine, because distillation POSTs each selected
drawer’s plaintext to UNDERCROFT_LLM_URL: it binds the surface, the
destination host with any credentials stripped, the model, the scope,
whether it was a dry run — a dry run skips the facts, not the POSTs — and
how many drawers actually left, a count recorded on the error path too
(ROADMAP O79, O95). Two more records name what a served model is handed out
of storage (ROADMAP O167): repair, which under a served embedder re-embeds
every drawer through the endpoint, appends egress/embed/repair — surface,
destination host, model and how many drawers it sent, written after its
transaction commits or rolls back so an aborted repair still records what
left — and dedup appends egress/advise/dedup for the stored survivors its
admission screen showed the tier-2 advisor. The line is custody, not call
site: text read out of a stored drawer owes a record, and text a caller is
sending in — a save, an import, a query, the advisor’s view of a new write —
does not, its endpoint being named by the deployment’s configuration. Where
the vault already holds the vector a path would compute, it sends nothing:
remote search scores each verified hit from its stored embedding, and
admission allow and dedup reuse the vectors they rewrite. None of the
five is behind a declaration — an egress is worth recording whether or not
the deployment opted into anything.
Under UNDERCROFT_READ_AUDIT=chain each content-returning READ appends a
record too — searches, by-id and bulk drawer reads, and the knowledge graph’s
own doors — carrying a keyed fingerprint of the subject (never its text),
the scope and the count.
What it covers, since 2026-08-18 (ROADMAP O50 and O51). Every
content-returning read appends exactly one chain record, across both
funnels. The drawer funnel: search, get, recent, the drawer list, a
diary read, a tunnel follow, the closet index, hallways and the
admission queue listing. The knowledge graph, which distills drawer words
into facts and returns them through its own readers: kg-query,
kg-timeline, kg-entities and kg-canonical.
Until O50 it covered searches only — get and the bulk reads returned
verbatim content and appended nothing, so an insider holding a valid token
could walk GET /v1/…/drawers for ids and GET …/drawers/{id} for each and
exfiltrate the whole vault leaving zero records, while the same person
running one search left one. That is the opposite of what this row is for,
and it was accurate-but-narrow on every prose surface (“one record per
search”) while being enumerated as a limit nowhere. O50 closed the drawer
half; O51 closed the graph half, which mattered because the graph is where a
long-running agent’s distilled memory of a corpus actually lives — walking
kg-entities for names and then kg-query per name reads the same corpus
through a different door.
Two exclusions, both deliberate and both stated. kg_verify_receipts
and kg_stats return identifiers, verdicts and counts; they reach neither
word decoder, and the one drawer the receipt walk reads it reads internally
to compare a fingerprint that never leaves. And the engine’s own reads are
silent by design, each saying why through an InternalRead variant:
hydration inside a search that already records, lookups performed to decide
a write, index maintenance, verification, a policy fence, and an export that
audit_export already records unconditionally.
The residual, narrower than before but real: the Read witness is a
required argument on every pub reader, so no SURFACE can forget it — but a
new pub store reader built on the private all_triples walk and reusing an
existing ReadOp would pass the both-ways namespace gate while recording
nothing. The drawer funnel carries the identical residual for a reader that
avoids get/recent.
Three boundaries come with it, all stated rather than hidden. A
read-only process cannot append, so it serves an export and says the
egress went unaudited, and it disables read auditing with a warning at
open — the replica precedent: warn and serve, never silently pretend.
And read records are appended without advancing the manifest anchor;
they anchor at the next store open, so a stripped unanchored tail is
indistinguishable from a crash until then. A long-lived server never
re-opens, so since 1.0.0 the window has an explicit closer —
POST /v1/vaults/{id}/anchor, a write, refused on a read-only handle
(ROADMAP R3). It is deliberately not an MCP tool: it fsyncs the
out-of-database manifest a rollback is detected against, and the surface
an agent drives must not move that onto whatever the database currently
says.
6. Verbatim storage as a security property
The market treats “what to store” as a quality trade-off. It is also a security decision, and the measured benchmark rows make the stakes concrete (BENCHMARKS_VS.md): extraction pipelines retained 55 memories from 177 ingested chunks — content their rubric judged uninteresting simply ceased to exist. Applied to security:
- Evidence: a verbatim store with per-record MACs and a write-order chain is usable in an incident investigation; a store of LLM paraphrases is not — the original words are gone and the paraphrase was produced by the very class of component the attacker manipulates.
- No silent belief formation: an extraction pipeline decides during the write what is true enough to keep. Under poisoning, that decision launders the attack. A verbatim store defers interpretation to retrieval time, where provenance is still attached.
- Deletion that means something: you can only prove you deleted what you can identify. Verbatim records are identifiable; facts blended from many sources are not. The shipped retention and attested-forgetting work is built directly on that — §9.
7. Custody boundary (stated for operators)
At runtime the operating machine holds the master key; an operator of a hosted deployment therefore can read tenant vaults while the process runs. The honest formulation: undercroft provides cryptographic isolation between tenants and against everyone who does not operate the host, and evidence-grade integrity against everyone including the operator. Bring-your-own-key / HSM custody — closing the operator gap — is roadmap, not shipped, and hosted-offering material must not claim otherwise.
8. Memory as an attack vector on the agent and the host
Adversary A7 (§3) covers writing poison into the store. But the sharper question is what happens downstream: poisoned memory is a vector to attack the agent that reads it, and through an over-privileged agent, the host it runs on. A memory layer must be precise about how much of that it can own — over-claiming here is exactly the security theater this document refuses. The attack crosses three trust zones with three different owners.
Zone 1 — the memory store (undercroft owns this)
Reduce and mark what can ever reach the agent. This is where the C3.3 write-path admission control lives — BUILT 2026-08-03/04 — and it is a genuine category-difference: no surveyed competitor screens the write path at all.
- Provenance on every write (BUILT) —
agent/channel/sessionclaims on every save surface, tamper-covered by the record HMAC, and deliberately never themselves a trust boundary: the trusted-surface posture keys on the handler-stampedadded_by, never on a claim. - Admission check at ingest (BUILT; opt-in via
UNDERCROFT_ADMISSION=quarantine— screening changes what a save does, so it ships as the deployment’s declaration). It runs at the single write choke point every write funnels through, and every caller must state its decision in a requiredScreenargument. That is not decoration: screening used to be applied at call sites, and a surface audit found three ways past it on/v1alone — adedup_thresholdin the body routed to the dedup writer, a caller-suppliedvectorrouted import to the raw writer (so backup-restore and orchestrator tenant migration re-admitted whole corpora unscreened), and external-embedding vaults had no screened path at all. Each was a call site someone forgot, and nothing could have told them. AScreenargument cannot be forgotten: a new write path does not compile until its author decides, and the only two bypasses are named, greppable variants carrying the reason they are allowed. The shipped tier-1 detector is deterministic: imperative-instruction patterns, embedded tool-call syntax, exfil markers, encoded blobs, known-attack-fixture similarity (windowed hash-embedder cosine against a committed fixture corpus — catches the variant that dodges every marker substring; threshold pinned from both sides and measured at 0/5,882 false positives on clean LoCoMo with 18/18 fixtures tripping), and a declared per-writer rate screen (UNDERCROFT_ADMISSION_RATE— the tier-1 signal candidate bytes cannot carry, checked where the write history lives) — pure functions over bytes plus the candidate’s deterministic embedding, no model, no network. The optional, advisory-only local classifier (UNDERCROFT_ADMISSION_LLM=advisory) can push toward quarantine and never auto-admit — never consulted for tier-1-flagged content, so it is itself an injection target that a successful injection can only steer in the safe direction. - Quarantine wing (BUILT) — flagged writes divert sealed into the
reserved
quarantine-pendingwing, excluded from every read that returns content unless the caller explicitly names the wing:search,recent(which is whatwake_upand the closet index ride — the two surfaces whose whole job is loading context at session start, i.e. exactly where injected text wants to be),list_drawers, the duplicate-check oracle, anddedup. Exclusion lived insearchalone until 2026-08-04, so a diverted drawer was invisible to a query and then handed to the agent verbatim at the next wake-up. Over MCP the wing is not reachable at all: one fence over the raw argument map refuses any argument naming the wing and any*idargument naming a resident, so a tool added later inherits it without its author remembering — ruling on quarantined evidence is an operator act (undercroft admission …,GET /v1/vaults/<id>/admission), not an agent one. The wing refuses forged residents (aiming a save at it is a typed 400, never a 500), and quarantine-pending drawers are not editable — nor replaceable by an import (ROADMAP O216). A queue id is deterministic and a diverted save’s 202 names it, so an import record declaring an ordinary wing under that id replaced the row and emptied the queue withverifyOK; through the orchestrator’s/t/import, the agent whose save was diverted could erase its own evidence. The import door refuses it on every surface, reading the HMAC-covered wing. Nor replaceable by a later diversion (ROADMAP O220): the queue id is a function of the filing, so a second flagged text for the same filing — a re-mine of an edited file, two flagged updates of one drawer (an agent alone, over MCP), a restore — replaced the text under review, and anallowreleased whatever the row held when it ran. Each distinct flagged text now takes its own slot, keyed with the stored KG secret; equal text converges; a backstop inside the write transaction refuses a raced write. Nor re-filed over what the screen never saw (ROADMAP O224): an allow replaced whatever its destination held, so a flagged update an agent parked over MCP before a clean one reverted the drawer when a reviewer allowed it, and a drawer forgotten in between came back and failed its own erasure receipt as tampered. A queue row now records what its destination held when the text was queued — a digest keyed with the stored KG secret, so it confirms nothing to an offline reader — and an allow over a destination written or deleted since is refused, checked again inside the write transaction;admission listshows the state before anyone rules. Nor re-declared by a convergence (ROADMAP O226): a re-submission of the same text for the same filing replaced the pending row’s whole covered declaration, so a converger holding the text could back-datefiled_at(the allowed drawer then swept at once) or attach akindor asupersedeslink the reviewer never saw; it now carries the row’s declaration, the screen’s signals the one field that grows. And “pending” is the HMAC-covered wing (ROADMAP O221): every destruction and write fence —drawer delete,delete_by_source,forgetwith or without a mirror, the import door, both dedup doors and the retention sweep — asks one predicate that reads the covered wing wherever the row verifies and the clear column only where it does not, so one offline flip of the clearwingcolumn no longer launders the destruction of review evidence; the read fences refuse when EITHER copy names the queue; andadmission listenumerates by the same rule and reports a row it cannot verify beside the rest instead of failing on it. What remains, stated: a ruling still binds the id and not the text (O225); a row whose tag fails can be neither allowed, denied nor deleted until its explicit destroy-unverifiable act is built (O221(c), ruled D4); and a row whose tag, clear column andmeta_jsonwere ALL rewritten is outside the fence and the report, whichverify’sbad_recordsnames. Updates are screened on the UPDATING surface, so an untrusted surface cannot ride a trusted writer’s standing. Deployment-trusted surfaces bypass by declaration (UNDERCROFT_ADMIT_TRUSTED_SOURCES). A diverted save says so on every surface —/v1answers 202 withquarantined: true, MCP and CLI say the write is not retrievable, and all three report the id the drawer actually landed under rather than the one the caller aimed at. - Full lifecycle audit (BUILT) — quarantine, allow, and deny are each chain-logged with the verdict inside the ruling tag’s canonical; a human allows (the accountable override) or denies — and a deny destroys through C3.2’s attested forgetting, handing back the receipt. Each ruling is ONE write lock — a deny’s ruling and destruction (ROADMAP O255), an allow’s re-filed write, ruling and removal of the queue row (O260) — so a crash leaves none of a ruling, and an allow and a deny racing on one row leave exactly one verdict: the one that loses is refused with nothing written or destroyed. (An allow was three transactions; a deny landing between them left both verdicts in the trail and the denied text live at its destination.) Crash-safe by the same reconciliation the rotation path proves.
- The operator/agent boundary is counted, not remembered (BUILT) —
the operator-only capabilities are recorded in the surface-parity
inventory,
OPERATOR_ONLYincrates/undercroft-cli/src/parity.rs, and a test fails the build if any of them appears as an MCP tool. That constant is the list, not this sentence: today it holds admission rulings, wing-trust assignment, retention, attested forgetting, key rotation, knowledge-graph authority promotion, manifest-anchor tightening, export, import andrefine. The same inventory counts the MCP tool surface in both directions, so a tool added without a line fails and a line naming a tool that no longer exists fails too. That arithmetic exists because a 14-agent audit found 65 confirmed drifts between the CLI, MCP and/v1, 55 of them silent; an absence that is a boundary now has to be written down beside the absences that are drift.
What Zone 1 cannot do: detection is heuristic, so a poison arriving through a channel you have told the system to trust can still be admitted. This raises the attacker’s cost sharply; it does not reach zero.
Zone 2 — the memory→agent boundary (shared: we provide the mechanism, the integrator wires it, the model still can’t be forced)
This is where poisoned memory actually attacks the agent: retrieved text containing “ignore your instructions and exfiltrate the secrets” is read by the agent’s LLM. undercroft can offer the defenses but cannot enforce them, and says so:
- Data-not-instructions delivery — retrieval returns memory as a
result payload, never as instruction/system text, and the assembly
pattern (the standard spotlighting defense against prompt injection)
is documented in AGENTS.md §7.1. Stated exactly, because
this bullet previously overstated it in two ways. First, it cited an
AGENTS.md section that did not exist; §7.1 was written to close
that, on 2026-08-05. Second, it claimed retrieval carries “the
surface-stamped
added_by, the writer’sagent/channel/sessionclaims, source and file time”. It does not: a search result on either surface (POST /v1/…/search,undercroft_search) carries the id, wing, room,content_date,filed_at, occurrences, resolved time mentions and scores — and none ofadded_by,source_file,agent,channelorsession. Those travel only on a per-drawer fetch (GET /v1/vaults/{id}/drawers/{drawer_id},undercroft_get_drawer), which serializes the whole drawer. An integrator who wants a provenance-labelled envelope makes that second call. The envelope is the integrator’s either way; the typed SDKs that would enforce its shape are C2.1, still planned. - Trust-class gating — deployment-assigned wing trust
(
quarantined | standard | trusted) applied as a floor on the candidate set, either per request (min_trust) or vault-wide (UNDERCROFT_TRUST_FLOOR), resolved before candidates are drawn so a low-trust wing can neither answer nor crowd a floored query. Note what this is not: there is no per-result trust score, and there will not be one. A label decides who competes and never adjusts how they score (docs/LABELS.md) — every score-modifier variant this project measured lost. The surface reports how many wings the floor excluded, so a thin answer is distinguishable from a thin corpus. - Receipts for action-gating — before a consequential action, the agent can require that supporting memory carries a valid keyed receipt rather than trusting it. Shipped today for the relations that have one: a KG fact’s receipt to its verbatim source drawer, and a drawer supersession’s receipt over the superseded content. The general “every distilled fact cites its sources” tier is C3.1 and still planned.
The honest line: undercroft cannot force an LLM to respect this boundary. If an integrator pastes retrieved text into the instruction channel, labeling does not stop the model obeying it — prompt injection is unsolved at the model layer. We supply the mechanism and the recommended pattern; the integrator must wire it.
Zone 3 — the agent→host boundary (not ours, and we do not claim it)
“Through the agent to attack the host” means the agent has tools — shell, filesystem, network — and admitted memory induces a malicious tool call. The only sound defense is that the agent’s action surface is sandboxed and least-privileged: tool calls gated by policy or human approval, no raw shell, restricted filesystem and egress, scoped capabilities. That is the agent runtime’s and the OS’s responsibility — precisely the A8 process/host non-goal. A memory layer cannot secure a host whose agent runtime hands an LLM’s output straight to a shell. We document agent-action sandboxing as a required companion control, not a undercroft feature.
The one guarantee that holds across all three zones
undercroft is an inert store: it never executes retrieved content, never interprets it as commands, never acts on what a drawer says. The memory layer is therefore never itself the code-execution vector — a poisoned record cannot make undercroft do anything. The danger is entirely downstream, in components we are honest about not being.
The posture, stated once: undercroft provides the materials to defend Zones 1 and 2 — trust-classed, provenance-tagged, receipt-verifiable, admission-controlled memory that no competitor offers — and is explicit that Zone 3 belongs to the runtime. Defense-in-depth with a drawn responsibility boundary is a posture a serious operator respects; “our memory makes your agent safe” is a claim they would rightly distrust.
9. Phase C3 — status, planned labeled as planned (ROADMAP C3)
One item of this cluster is still design; the other three shipped inside a week. The section keeps all four so the record reads straight, each carrying what it actually is.
- Facts-with-receipts (C3.1) — the one still PLANNED: optional distillation on top of verbatim — every derived fact HMAC-cited to its source drawers, so compression never costs provenance. Gated: ships only if it beats the retrieval-only baseline. Two of its materials exist already and are shipped independently of it: KG facts carry receipts to the verbatim source, and extractor identity — which model claimed a fact — lives inside the fact’s own HMAC, so a flipped attribution fails verification.
- Provable forgetting (C3.2) — BUILT (2026-08-03), both phases:
forgetdestroys named drawers through the chain and emits an attestation (ids + content fingerprints, heads before/after, the tombstone interval, optional Ed25519 signature). Those fingerprints stay unkeyed where U12 keyed the two stored ones, deliberately and for the opposite reason: this value is signed and handed to a data subject who checks it against content they already hold, without the vault key — and it names content the vault no longer has, rather than sitting at rest beside content it does;verify-forgettingreplays it with the key in hand — while that key exists: a key rotation destroys it by design — though not wholly (ROADMAP O267): a rotation changes the vault’s salt and not the master key, and every oldervault.jsonstill names the old salt, every archivebackup createtook before the rotation included, so a holder of the master key and such a manifest can re-derive the retired keys — so from then on the same command reports the reduced verdict (the preserved audit trail holds those tombstones contiguously and the drawers are gone) rather than a replay, at exit 0. Reporting that case as forged, with the tamper exit code, was ROADMAP O13. Retention policies per wing/room ride the wing-trust pattern — operator-only, HMAC-tagged, chain-audited, and enforced by an explicit sweep through the same attested path, never on a timer and never at open. The clock is the HMAC-coveredmeta.filed_at, tag-verified per drawer, and since ROADMAP O206 so is the scope: the sweep walks every drawer and reads its wing and room from the covered meta, where it used to draw candidates from the clear mirror and so kept any drawer whose mirror had been flipped out of the policy. So a flipped clear column can neither launder a deletion nor hide a drawer from its declared retention. A row whose tag fails is named wherever it sits and destroyed nowhere; the sweep answersok: false(200 on/v1, exit 2 on the CLI and the orchestrator) and still destroys everything it could decide. Honest boundary: a third party verifies the operator’s signature, not the replay — the chain step is keyed. Asigfield is not by itself evidence of one, and saying so is 1.1.0’s correction: verification runs againstsender, the public key, so a document carrying a signature with no sender can be checked by nobody. That shape was skipped rather than refused, and the CLI reported “sender signature verified” over it on the strength ofsigbeing present. It is refused now, and every operator door can run the check —verify-forgettingon the CLI,POST /v1/vaults/{id}/verify-forgetting, the fleet’sops/verify-forgetting, and the admin console — where until 1.1.0 the HTTP plane could MINT a receipt and only the CLI could check one (ROADMAP O14). A receipt carrying what this build does not know is NOT CHECKED, never forged (ROADMAP O315, 1.7.0): the version and every key are read BEFORE the signature, and one this build does not know is refused unread — exit 1,/v1400 with no class — where a field a later release signed used to fail the signature and read as the tamper verdict (1.6.1 and older still read it so). The witness’s check shares the fence. Reading them ahead of the signature can buy a refusal and never an acceptance. Its cost, stated exactly: a forger who adds a key no build will ever declare, or a version none will ever mint, gets exit 1 from EVERY build, present and future, where the same forgery without it is exit 2 — and exit 1 is the class a compliance script may retry and then ignore. Whether “not checked” needs its own exit class is open in the ROADMAP for the maintainer. A whole destruction is one write lock (ROADMAP O255, 2026-09-25). Until then each drawer was destroyed in its own transaction, so a receipt minted while another handle committed carried that writer’s records — and labels — inside its interval and could never verify, and the retention sweep decided on one state and destroyed on a later one, destroying drawers aretention setmade meanwhile would keep (measured: 58 of 64 sweeps with a re-declaration landing mid-sweep). Now every drawer of aforget, a sweep or anadmission denyis destroyed in one transaction; the receipt is read from it, its fingerprints from the bytes that transaction destroyed, and its records are asserted to be exactly its tombstones before the COMMIT; the sweep re-checks each member, and the declarations in force, inside that lock; and any failure before the COMMIT destroys nothing. Residuals, stated: a tamper committed between the label guard and the lock is not seen by that destruction (the guard stays outside, where it replays in a read snapshot); a crash after the COMMIT and before the receipt reaches its caller loses the receipt (its fingerprints are persisted nowhere, by design) — the CLI no longer loses it to a bad--signor--out(O262, 1.7.0: the identity is checked before anything is opened or destroyed,--outis written and synced before anything is printed, and an--outthat cannot be written puts the receipt on stdout before the command fails — or says it is lost, when stdout cannot take it either);forget --backenddeletes from the mirror before the lock (O175’s order); a sweep’s membership is as of its walk; the lock is held for the whole destruction — 2.5 s at 40,000 drawers with the retrieval tiers built — so other writers wait, and past their 5 s busy timeout are refused with nothing stored (O264, O258); and receipts minted by earlier builds beside a writer still fail as forged (O261). - Memory-poisoning defense (C3.3) — BUILT (2026-08-03/04): write-path admission control — provenance on every write, a deterministic (optionally classifier-assisted) detector at the write choke point, a retrieval-excluded quarantine wing with a crash-safe human allow/deny gate, and a full lifecycle audit (quarantine and denial each logged with their reason). The direct answer to MINJA/AgentPoison-class attacks, built on the attribution machinery that already existed. Full design in §8 above.
- Post-quantum posture (C3.4) — BUILT (2026-08-04): the at-rest
stack is symmetric-first and already conservative against quantum
adversaries (256-bit XChaCha20 keys, HMAC-SHA256, HKDF); the one
asymmetric exchange — the export bundle’s X25519 — is now hybrid
X25519 + ML-KEM-768 by default (
bundle keygen), with legacy identities fully supported and downgrade refused in every direction that matters — a hybrid recipient never silently accepts a legacy bundle as hybrid, and an X25519-only secret is refused a v2 outright. The one direction deliberately allowed is a hybrid identity opening an old v1 backup with its curve half, so upgrading an identity never orphans existing backups. Full inventory, compat matrix, and deployment guidance in PQ.md. No “quantum” marketing beyond this paragraph.
10. Audit us
Every claim above is checkable without permission: the implementation
is source-available (BUSL-1.1), the tests assert at-rest
opacity and chain behavior (docker compose run --rm test), the e2e
suites exercise rotation, tamper alarms, and auth refusals end-to-end,
and the benchmark logs behind every measured number ship in
benchmarks/logs/. Vulnerability reports go
through private disclosure — including anything in
this document you believe is overstated. That standing offer is part of
the threat model: a security story that cannot absorb adversarial
review is not one.