Tamper runbook
When Undercroft raises PalaceTamperDetected (or the Palace Monitor’s
ambulance beacon lights, or undercroft verify reports a non-zero hmac failures count), a stored record failed its HMAC integrity tag on read. Treat
it as on-disk tampering until proven otherwise. This page is what the
alert’s runbook_url points to.
Integrity is cryptographic, not advisory: every drawer, KG triple, tunnel, and vault manifest carries an HMAC-SHA256 tag, and every write joins a tamper-evident audit chain. A verify failure means the bytes on disk no longer match what Undercroft sealed.
The whole procedure at a glance — each step is detailed below:
flowchart TB
alert["PalaceTamperDetected<br/><i>alert / monitor beacon / verify count</i>"] --> loc["1 · Where?<br/><i>surface label on the counter,<br/>vault · id · wing · room on the event</i>"]
loc --> conf["2 · Confirm + pinpoint<br/><i>undercroft verify --vault —<br/>names the exact record(s), chain state</i>"]
conf --> mit["3 · Mitigate<br/><i>preserve evidence copy FIRST ·<br/>freeze writes (--read-only) · isolate vault</i>"]
mit --> fix{"4 · Fix — verbatim restore,<br/>never repair-in-place"}
fix -- "known-good backup" --> restore["backup restore →<br/>verify must report 0 failures"]
fix -- "single MINED record,<br/>source document available" --> refile["re-file it —<br/><i>source-derived id ⇒ idempotent re-seal</i>"]
restore --> clean["repair (housekeeping) →<br/>read-write only once verify is clean"]
refile --> clean
clean --> prev["5 · Prevent<br/><i>scheduled backups ·<br/>owner-only permissions ·<br/>OS-level FIM · alerting on ·<br/>per-vault assertions</i>"]
1. Where did it happen?
The alert carries two labels that localize the failure:
surface— which structure failed:drawer,kg,tunnel, ormanifest.instance— which server process counted it.
There is no vault label on this alert: the integrity counter is emitted
with surface alone. (One rule does carry one — AuditChainHeightHigh, whose
expression is an unaggregated gauge, and gauges have always been per-vault.
This sentence said “or any other” until ROADMAP O250 added it.) The vault is
on the live event stream
instead — the hmac-fail frame the Palace Monitor reads names it, beside the
id, wing and room the failing row claims (marked unverified, since
that row has just failed its own HMAC). The stream is live only, so it names
the vault only to a subscriber connected when the failure happened; otherwise
run step 2’s verify against each vault the process serves.
In Grafana, the “Tamper by surface” panel and the HMAC verify failures
stat show the same signal; the Logs panel shows the
integrity failure — HMAC verification failed on <surface> line.
2. Confirm and pinpoint the record
Run a full verification of the affected vault — it re-checks every record’s HMAC and replays the audit chain, naming the exact bad record(s):
undercroft verify --vault <vault>
# records checked: 1284
# hmac failures: 1
# TAMPERED: 5a2fc91d…
# unreadable: 0
# audit chain: ok
# orphan labels: 0
# mirror drift: 0
# policy drift: 0
# version replay: 0
# storage: 0
# identity: 0
# field shapes: 0
# VERIFY FAILED
The named id is the tampered record, and VERIFY FAILED exits 2. Expect
audit chain: ok beside it: the chain is replayed from the audit trail’s own
tags against the committed head and the manifest anchor, so editing the
tampered record’s bytes does not move it. audit chain: BROKEN is a separate
finding — the audit trail itself was edited or truncated, or the database was
rolled back relative to the anchor.
unreadable: counts drawers whose HMAC VERIFIES while their covered meta does
not parse (ROADMAP O231), each named UNREADABLE: <id>: …. No read can return
one, it fails the verdict, and backup create refuses while it stands — but a
key rotation is not refused over it, since a rotation cannot launder it. It can
be made without the key (ROADMAP O406), so treat it as evidence:
preserve the directory first, then restore an archive that holds the drawer
intact, or drawer delete it if its content is not needed. Since 1.7.0 it
also counts a row a column of which holds what no read takes — the wrong
storage class, or text that is not UTF-8 — named UNREADABLE: <id>: column <name> holds … (or by seq where its id does not decode), where verify
used to stop with a conversion error; that one DOES block a rotation, which
cannot read it (ROADMAP O269).
storage: is SQLite’s own integrity_check of the file the rows live in, run
first (ROADMAP O269). STORAGE: integrity_check: row 11 missing from index idx_drawers_room names an index that disagrees with its table: an index hides
rows from every read that finds one through it — forget included — so a vault
can answer every other leg clean and still serve wrong answers. Restore the
newest archive whose restore is accepted; a restore refuses one carrying the same
damage. undercroft repair does not mend it, on purpose: its closing VACUUM
copies an index as it stands, and rebuilding the index from its table would
conform it to the table — while one byte changed on the TABLE side of an indexed
column gives exactly this line, so a rebuild could turn an edit into a clean
verify (ROADMAP O434 asks whether a rebuild that first rules that out
should exist). STORAGE: <leg> not run: database disk image is malformed is a
leg that met damaged pages partway; and a file damaged at its first pages
refuses to open at all, exit 2 with the vault’s database is damaged. All of
them mean restore. One exception is not damage: a read-only handle that had to
open its database with immutable=1 — a write-protected mount, a snapshot —
answers the storage leg cannot be verified on this handle, exit 1, rather
than a storage line, because beside a process still writing the vault its reads
can tear; run verify where the directory is writable, or on a copy.
A broken chain also stops work before you get here, and that is deliberate
(ROADMAP O237). Every read that DECIDES from an audit label — a
trust-floored search, trust list, retention list, a retention sweep,
forget, and the version check every returning read rides — refuses with
the audit chain does not authenticate its own labels, exit 2 and 409
class: "integrity", rather than acting on a relabelled record until
somebody runs this command. So an operator usually arrives here because a
read refused, not because an alert fired. The remedy is the same: preserve
the evidence, then restore a backup that verifies. A vault this binary has
not yet opened writable keeps serving, because its labels were never bound
to the chain.
The next five lines are further legs, and a non-zero count on any of them fails the verdict too; a vault holding supersession links or fact receipts prints a line for each of those legs as well, where only a tampered count fails.
3. Mitigate now (stop the bleeding)
-
Preserve evidence first. Copy the vault directory before anything else touches it — the DB, its
-wal/-shm,vault.json, andvault.json.nextif one is there:cp -a "$UNDERCROFT_HOME/vaults/<vault>" "/tmp/<vault>.evidence.$(date +%s)"Since 1.2.1 a read-only open (see step 2) writes nothing to the database,
vault.json,vault.json.nextor a hot-wal. In a writable directory it may still create the-shmwal-index and a zero-length-wal— SQLite’s scaffolding for reading a WAL database, carrying no database content. From 1.0.0 through 1.2.0 an embedder lookup ran before the posture took effect — on a/v1request from 1.0.0, and on the CLI’s own read-only open from 1.2.0 — and could create a missing database or checkpoint a crashed writer’s hot-walinto it (ROADMAP O91), so on those versions the copy has to come before any process opens the vault. Take the copy on every version: it is the only thing that survives a writable process someone else starts, and a forensic copy costs seconds.backup createis not this copy: it archives only a vault that verifies, prunes the vault’s oldest archive once ten exist — perhaps the one taken before the incident — and a writable one first heals a lagging anchor and promotes a deferred key rotation. Since 1.7.0--read-only backup createrefuses before it writes anything underbackups/or into the vault’s database or manifest (ROADMAP O212) — its read-only open, like every one, may still leave the scaffolding described above, which is why the copy comes first. -
Freeze writes. Restart the server read-only so nothing new is written on top of a compromised store while you investigate:
undercroft serve-http --read-only …--read-onlyis a posture on the whole process, not a filter on one port: both stores the server opens take it, the gate sits in front of route dispatch and fails closed (everything is a mutation unless explicitly named otherwise), and the read-audit record and the embedder migration — both writes — are suppressed.POST …/verifyis allowed and is a genuine read: it walks every record’s HMAC and replays the chain, and it does not fast-forward the manifest anchor (an earlier version of this step said it did).The open is a read too — the open itself since 1.0.0, and the embedder lookup that ran ahead of it since 1.2.1 (step 1). It used to be the one write
--read-onlydid not bound, and the worst of it ran on the very path this step recommends: rotation reconciliation happened before the read-only/read-write split, so the first request against a cold handle either promoted a stagedvault.json.nextovervault.json— adopting a new key generation — or deleted it outright, with an fsync. That was potential evidence destruction on the path chosen to avoid touching the vault (ROADMAP R4/A32). Now the connection itself is openedSQLITE_OPEN_READ_ONLYunderPRAGMA query_only=ON, the schema is checked rather than created, the anchor is reported rather than healed, a staged rotation is honoured in memory only and its file left exactly where it is — and, since 1.7.0, verified against: a rotation whose promote was deferred (or a writer that crashed between its rotation’s commit and its promote) opens read-only, verifies against the STAGED manifest, and says so onunhealed. From 1.1.0 until then that open answeredManifestTampered, exit 2 — if you met it right after a rotation, a writable open would have promoted and verified. The note is re-checked against the files once the open has its database (ROADMAP O288): if a promote, a discard or a restore lands between the open’s unlock and its database open, the command is refused with the reopen class and retried once, so the note is true at the moment the open checks it. It is not re-checked after that: a promote landing later — even within one command, and for the whole life of a long-lived server — leaves the note describing the vault as it was at that open, and the note says so (“at this open”); since ROADMAP O289 that server answers avault.jsonforged afterwards with the tamper verdict and its alert, as any server does. Do not deletevault.json.nextwhile it is there: until a writable open promotes it, it is the only file holding the vault’s current keys, and a read-only open refuses as an integrity verdict once it is gone. A live server whosevault.jsonis deleted, or replaced by anything that is not a manifest file, answers the integrity verdict onverify, the witness and a backup (ROADMAP O277) — it used to answerVERIFY OKover a vault no process could reopen. A writable open over a deferred rotation whosevault.jsonwent missing, or was edited, between its unlock and its database open refuses — the integrity verdict for a missing, torn or forged file, the age refusal for a too-new one, an I/O error for one it cannot read — and leaves both files exactly as found (ROADMAP O290): it used to write the staged manifest back from memory, which turned a database restored beneath it into a crash heal and overwrote a forged manifest with no alert. The same holds for an edit landing while that open replays the audit chain, just before it writes (ROADMAP O304): it asks again, and refuses with both files kept. A manifest of the new key generation put there meanwhile — another promote, or one copied by hand — is followed when it is the staged one; at any other head the open answersthis handle must be reopened, which the CLI and/v1retry once, and the reopen judges it. And a writable open whose DATABASE does not answer to the manifest it would adopt — its key-generation marker deleted or edited beside a deferred rotation, or an older copy of the database restored behind the manifest’s anchor — refuses before it removesvault.json.next, writesvault.jsonor re-seeds the marker, and leaves all three as found (ROADMAP O296): it used to delete the staged file, the only copy of the new keys, and only then refuse. An older copy restored exactly AT the anchor is indistinguishable from a rotation that crashed before its commit: the open discards the staged file and serves the older state, as a crash would — keep a copy ofvault.json.nextbefore restoring a database by hand beside it. A database whosechain_metaholds no committed head while its audit trail holds records — or a head with no committed height — is refused by a writable open with nothing written and reported by a read-only one; an audit trail erased beside a later anchor is the tamper verdict (ROADMAP O303). No release since 1.0.0 writes either state, so treat it as an edit and restore a backup that verifies: before 1.7.0 the open adopted it from the manifest and its first write moved the anchor over the evidence. A prefilter loads an index but never builds one. What the open declined to repair is printed as a warning and readable afterwards onundercroft stats(andGET /v1/vaults/{id}/stats) asunhealed— during an incident, read it: “vault.json.next does not authenticate … and was left in place” names a staged manifest this build cannot read — a torn write from a build before 1.7.0 or a file something else put there, which since 1.7.0 no open deletes on either posture (ROADMAP O257) — and since 1.7.0 (ROADMAP O246) a WRITABLE open reports on the same list the manifest anchor heal it performed and how far behind the anchor was — a crash, or another writer committing while the handle opened (ROADMAP O253), is the ordinary cause, and a genuine oldervault.jsonrestored beside a current database looks identical, so treat it as evidence to read during an incident rather than as an alarm. Since 1.2.0 the admin console atGET /uishows it too, as an UNHEALED panel that appears only when there is something to say, beside a POSTURE gauge naming the role the handle was opened under. Before that the console showed a clean, complete- looking stats page for a replica with both conditions live.Two conditions refuse instead, both 409, because serving through them would answer a question wrongly rather than partially: a manifest whose
vault.dbis absent (a half-copied backup or a snapshot taken mid-write — “empty” is not “absent”, and this one exits 2, an integrity verdict), and a schema this build would have had to migrate (open it once with a writable process, then retry).The database is
vault.dbsince 1.5.0, besidevault.json; before that it waspalace.db. A vault created earlier keeps that name until the first WRITABLE open that finds nothing else holding it, which checkpoints the WAL and renames it in place; beside a running replica or server the name is kept and that open says so onunhealed(ROADMAP O281 — renaming beneath a holder left it on a file nobody else shared). A read-only open serves the file where it is and reports the pending rename onunhealed, so a replica of a not-yet-migrated primary says so rather than failing. A directory holding BOTH files is refused on either posture (409, exit 2 — one of them is a stray copy, and an open that picked one would serve the wrong vault silently): move the stray aside and reopen. Any script of yours that names the file — backups, the tamper demo in the observability README, a monitor — must namevault.db, or check for both.If your incident needs a byte-frozen vault, stop the server rather than restarting it. If the vault lives on a write-protected mount or a snapshot, the read-only open escalates to SQLite’s
immutable=1mode and says so in a warning — correct there, and wrong if anything is still writing, which is why it is reached only after the ordinary open has failed. -
Isolate. If this is a multi-tenant server, the vault named by the
hmac-failevent (or byverify) scopes the blast radius — other vaults have independent HKDF-derived keys, so one vault falling tells an attacker nothing about its siblings.
4. Fix (restore verbatim)
Undercroft never lossily transforms your data, so the fix is a verbatim restore, not a repair-in-place of forged bytes:
- Restore from the most recent good backup.
backuprefuses to run if the source failed verification, so a listed backup is known-good at capture time — andrestoreproves it again before it touches the live vault: the archive is copied into a stage, opened with this installation’s key and the vault’s embedder, verified over every leg — SQLite’sintegrity_checkamong them, as the storage leg (ROADMAP O269) — and only then swapped in. An archive that fails any of it exits 2, prints what its verification found, and the live vault is left exactly as it was, which on this path is also your evidence (ROADMAP O268):
The restore prints the chain height and head the archive held — compare them with whatundercroft backup list # names are <vault>-<stamp> undercroft backup restore <vault>-<stamp> --force # stop the server first; --force to overwrite the live vault undercroft verify --vault <vault> # must now report 0 hmac failures, chain okbackup createprinted for that archive — and whether the vault it replaced had been rotated since (it brings the older keys back: rotate again if that rotation answered a compromise). Emit a new external witness afterwards; an older one reads the restored vault as rolled back, which it is. If the live vault’s directory holds a manifest and no database, restore refuses (O270): stop every process, move that directory aside, then restore. If it holds a symbolic link or anything but regular files — a link planted at its-walorvault.json.nextincluded — or is a link itself, restore refuses and names the entry (O283, O301): move the entry out, keeping it with your evidence, then restore. If a restore is interrupted between its two renames,vault create,initand another restore refuse and print themvthat puts the replaced vault back. - If a single mined record was hit and you have the source document,
re-mine it with the same path,
--wingand--modeit was mined with: a mined drawer’s id is derived from (wing, room, source, chunk index, normalize version), so re-mining rewrites that row under a fresh seal. Re-verify afterwards. This does not hold for drawers filed bysweep, which skips any message whose content fingerprint is already stored — and an edit to the content bytes leaves the fingerprint column in place, so a re-sweep counts the tampered message as already filed and never rewrites it. Nor does it hold for drawers written throughremember/ the API, which have no source and carry a unique append index instead — re-saving those creates a new drawer beside the tampered one rather than replacing it. Restore from backup is the only verbatim fix for both. - Housekeeping after a clean restore:
undercroft repair --vault <vault> # backfill fingerprints, re-embed every drawer + drop PQ/IVF (a served embedder receives the corpus, recorded as egress/embed/repair), vacuum, re-verify
Only return the server to read-write once verify is clean.
5. Prevent (before the next time)
- Back up on a schedule.
undercroft backup create --vault <vault>is the recovery path above; without a good backup, a verbatim restore isn’t possible. Only the ten most recent snapshots per vault are kept — older ones are pruned on each create, so a schedule needs its own off-box retention. Since 1.7.0 an archive is exactly the state its verify judged, even beside a running server, andbackup createprints the chain height it holds: record it beside the name. An archive taken by 1.6.1 or earlier while anything held the vault may be torn — restore it into a scratch data directory and runundercroft verifythere before restoring it over a vault, because a restore replaces the vault before it knows the archive opens (ROADMAP O268). - Lock down the store.
master.keyshould be0600and the vault directory0700(owner-only). Anything that can write the vault DB out-of-band can tamper; anything that can readmaster.keycan forge. - Back up the key material too.
backup createcopies the vault, notmaster.keyorkdf.salt, and a backup opens only under this installation’s key. Never delete either file to silence a message: the engine refuses a key source the installation contradicts rather than writing a new key (ROADMAP O204), and an installation an older release left holding both files may have vaults under each. - Add OS-level file-integrity monitoring (auditd / a tripwire) on the vault directory — Undercroft catches tamper on read; FIM catches the write.
- Keep telemetry alerting on.
PalaceTamperDetectedfires within a scrape interval — that early signal is the point. - Use per-vault assertions for multi-tenant deployments so a compromised client can’t reach another tenant’s vault.
- Witness the chain off-machine, on a cadence (ROADMAP O245). The one
rollback the anchor cannot see is a genuine earlier
(vault.db, vault.json)pair restored together —verifyis green on it by construction.undercroft witness emit --out <file>(orGET /v1/vaults/{id}/witnesson a served vault) writes a small document that binds the audit row count and an unkeyed digest over the rows’ preserved bytes; keep it somewhere the machine cannot write (a commit in another repository, an append-only log, mail to yourself — never under the data directory, which a backup restores with the vault), one file per emit, and check before you emit:undercroft witness check <file>(orPOST …/witnesswith the document as the body) answersWITNESS OKwhen the chain extends it andWITNESS FAILED, exit 2, when the vault has been rolled back below it. A key rotation does not fail the check — it retires the head’s corroboration and the check says so. Sign with--signand thebundle sign-keygenidentity if others can write to the witness store, and keep that key off the machine too, since it defaults to living besidemaster.key.
The guarantee
Tamper-evidence only works if the alarm is trustworthy — so Undercroft only ever
raises it on a real HMAC-verify failure. There are no synthetic or demo
tamper alarms anywhere in the system: metrics, the live event stream, and the
Palace Monitor beacon all read the same hmac_verify_failures signal.