Skip to content

Verifying integrity

Verification proves: at a chosen moment in time: that no row in a chain has been altered, removed or inserted since it was written.

The service

Drupal\audit_trail\AuditTrailVerifier walks a chain two ways: verifyChain() from genesis and verifyChainIncremental() from the last signed checkpoint. Both take a VerificationDepth saying how much signature verification to perform, which defaults to Strict. Which chains exist is Chain\ChainRepository's question, not the verifier's.

$verifier = \Drupal::service('audit_trail.verifier');

// Discover. The chain repository enumerates from the tables, so a
// chain whose config entity is gone still appears.
$chain_ids = \Drupal::service('audit_trail.chain_repository')->listChains();
// returns ['notarial', 'webdav', 'finance', …]

// Per chain, full walk from genesis.
$result = $verifier->verifyChain('notarial');

// All chains: incremental by default, pass full: TRUE for cold.
$all = $verifier->verifyAll();
// returns ['notarial' => […], 'webdav' => […], …]

Every verdict carries the same keys, always present, so a consumer needs no ?? at a read site:

key meaning
ok the verdict, structurally_ok and authenticated_ok combined
structurally_ok the public hash chain reconstructs
authenticated_ok the HMAC layer verifies; NULL means it was not checked
count rows examined
message_parts the verdict as the sentences it is built from, each a TranslatableMarkup. What a surface showing the verdict to an operator should render, because it is translated in the language of whoever is reading it rather than the language of whoever produced it. A part that is a plain string is the forensic detail of a broken row, which is still in the module's own words
message the human-readable verdict: those same sentences cast and joined with a space. Kept for drush audit_trail:verify and for any consumer written against it, and derived from message_parts so the two cannot drift
broken_ranges every contiguous failed range, not only the first
acknowledgments ranges an operator acknowledgment covered
archives purged ranges bridged by a segment's anchors
unauthenticated_attestations segments whose purge attestation was accepted without being checked: at Tolerant because the secret that signed it would not resolve, at None because no signature was read at all. Ordinarily empty at Strict, which fills it only where a segment's own signing secret will not resolve, and a chain still holding that segment's lifecycle events is reporting a break of its own by then
spine the segment spine's verdict: ok, height, head, attested_height, error. ok is NULL when no walk replayed it, the same way authenticated_ok is. The one key that does not vary with depth, because nothing in it reads a secret

verifyChainIncremental() adds checkpoint_minted, checkpoint_forged and checkpoint_stale, which only it computes, and walked_from_id: its walk read the rows above that id and none at or below it, 0 meaning it started at genesis.

verifyChain walks the chain in id order. For each row it runs five checks, in order. The first three are the layers the verdict's structurally_ok / authenticated_ok split is about:

  1. Chain link. Confirms row.previous_hash matches the previous row's hash column. Catches inserted, removed, or reordered rows.
  2. Public hash. Recomputes SHA-256(CanonicalJson::encode(payload)) and compares with row.hash in constant time. The canonical payload is built from the row's channel, chain, severity, action, resource, context_permanent, context_transient_hash, created, secret_id, and previous_hash columns: the raw context_transient is not in the canonical (only its write-time SHA-256 hash is), which is what lets the cron purge worker NULL the transient column at retention without breaking verification. This layer is publicly verifiable: no secret is required, anyone with read access to the row can reproduce the check.
  3. Operator HMAC. Loads the secret keyed by row.secret_id from the configured SecretRepository (Key-module backend), recomputes HMAC-SHA-256(row.hash, secret) and compares with row.hmac in constant time (hash_equals). Catches rows inserted directly into the DB by an attacker who has table write access but lacks the signing secret.

The two-layer split is deliberate: layer 2 surfaces tampering even when the row's secret is unavailable (rotated out, key deletion); layer 3 detects unsigned forgeries. A mismatch at layer 1 or 2 surfaces as a structural break; a mismatch at layer 3 (or a missing secret) surfaces as an authentication break. The verifier reports both flags independently on the verdict.

Two more are checked on every row, and neither needs a secret.

  1. Segment-event cross reference. A row whose action starts segment_ is a lifecycle attestation, and it names the segment it is about in resource. That segment has to be in the index and on this chain, and for the four transitions the segment records against a column of its own (segment_transient_purged, segment_archived, segment_live_purged, segment_file_purged) it has to point back at this row through the matching *_event_id. The other three are checked differently and deliberately: segment_created IS the segment, so its own audit_trail.id is the id and the range it recorded is compared against the range the index holds; segment_restored and segment_compacted have no column to point back through and are accepted on the identity check alone. Between them these catch a segment row rolled back to a pre-transition state, an event forged on a chain that has no such segment, an event naming a segment on another chain, and a segment whose range moved after it was recorded.
  2. Transient column. An empty context_transient whose signed write-time hash says the bucket held something has to be accounted for by a segment carrying transient_purged_at or archived_at. A column emptied with no segment claiming it at all is a break at every depth.

Whether that segment's stamps are the operator's is an HMAC question, and it is one of the two things the three depths differ about. The other is check 3 itself: None does not run it, and resolves no secret to run it with, so on that depth the HMAC layer is not failed but unasked. What follows is the first difference, the one about the attestation behind an emptied column. Strict treats a secret that will not resolve as a finding: the caller said they expect every secret to resolve here, so one that does not is about the key rather than about them. Tolerant re-derives the signature whenever it can, and reports a segment whose signature does not hold as a break, so a fabricated attestation is still caught on any host with a copy of the key; where the secret cannot be resolved it accepts the claim and names the segment in unauthenticated_attestations, because the alternative is to call a healthy chain broken. None reads no signature, so it names every claiming segment and calls none of them forged: it has no basis to.

Run any of the three from the command line with drush audit_trail:verify --full --depth=strict|tolerant|none. The option requires --full, because the incremental walk reads a signed checkpoint and mints another and both need the secret, and AuditTrailVerifier::verifyAll() refuses the same pairing rather than ignoring the argument. The run prints the depth it walked at beside the verdicts, so an OK from a walk that read no signature cannot be mistaken for an OK from one that read every signature.

One consequence is worth knowing before running either of the looser walks on a long-lived install. The bridge across a gap left by an archive and live-purge rests on the segment's anchors, and a walk that cannot read the signatures over them falls back to the spine, which takes no secret. That crossing succeeds only where the spine replays and a surviving lifecycle event still corroborates a head at or past the segment's height; where it does not, the gap reads as a broken link. The chain is not at fault and the verdict is not wrong; the walk is answering about the evidence it was given.

None of the three claims more than its evidence supports. A segment accepted without being checked is listed rather than believed silently, authenticated_ok is NULL whenever anything was skipped rather than checked, and the message says so on a clean verdict and a broken one alike. A Tolerant walk whose keyring turned out to be whole reports TRUE: the flag says what the walk did, not what it was asked for.

The walk does not stop at the first break. It records the broken range and keeps going, so a single call reports every contiguous failed range on the chain in broken_ranges, and the message names the first one and says how many more there are. That matters to an operator: acknowledging the first range and assuming the rest is clean is the mistake the count exists to prevent. See the multi-tamper walk in architecture.

Per-row secret_id dispatch means a single chain can span any number of rotated secrets without breaking verification: older rows verify under their original signing secret, newer rows under the current one. A row referencing a secret id that no longer exists (a deleted secret, or a forged value) is reported at the row's id as a secret that is not available, which the operator investigates as either a legitimate retirement (cross-check WORM archive) or a forgery attempt.

The id is computed from the secret's bytes, so a Key whose bytes changed behind its secret gives another id. The status report checks that for every secret, retired ones included, and names the Key: that is the explanation to look for when a range the secret signed reads as tampered.

The segment spine

Every other answer in this verdict is conditional on a signature, which means conditional on the operator. The spine is not. It replays the chain's archived history from the segment table, deriving each segment's digest from that segment's own fields, and compares the head it reaches against the head the chain recorded at the time. The one exception is a consolidated row left by compaction, whose head is adopted rather than derived, for the reason given at the end of this section. No secret goes in, so the same answer comes back from Strict and from None, and a host holding no signing material gets the full verdict on it.

Four things it reports:

  • ok, whether every step folded as recorded.
  • height, the greatest height the replay reached. Not a count of rows in the table: a consolidated row stands at the height of the highest segment it absorbed, so one row can advance the height by many.
  • head, where the chain's archived history currently stands.
  • attested_height, the greatest height a surviving segment_archived or segment_compacted event still corroborates. Both carry a head: an ordinary segment's is written when it is folded in, and a consolidated row's when compaction gives it the height it adopted. The compaction event is the newer of the two, so on a chain old enough to have compacted, it outlives the archive events it replaced and is what still corroborates that stretch.

That last one is the honest part, and it is worth reading before trusting the first. A replay that agrees with itself proves nothing: the spine lives in the same database as the segments, so anyone able to rewrite them can recompute it end to end. What makes it evidence is the comparison against a head recorded inside a chain event, where the public hash layer covers it and an attacker without the signing secret cannot follow. attested_height of 0 means nothing left on this chain corroborates the replay, and the verdict says so in its message rather than reporting arithmetic as proof.

A broken spine makes ok FALSE and leaves structurally_ok alone. The rows are not what broke: a chain whose every row links and hashes can still have a segment table that no longer folds to the heads the chain recorded, and those are two different findings an operator needs to tell apart. But the cron integration below alerts on ok, and a finding nothing alerts on is a finding nobody reads.

Bridging a purged range without a secret

A candidate whose signatures this walk did not read, because the secret would not resolve or because the depth reads none, is accepted when the spine covers it and the chain still corroborates a head at or past its height. At or past, rather than exactly at, because the spine is a chain: a head corroborated at height 40 is reached by folding every digest below it, so an event surviving there vouches for the thirty-nine segments before it as surely as for its own. That is what keeps this usable on a chain whose early lifecycle events were purged long ago.

It is what makes the three depths agree about a chain retention has run on. Without it a keyless walk reports the row after every purged range as a broken link while an operator's walk of the same chain reports it verified, which inverts the ladder: the depth an operator runs before handing a copy to an auditor is the one that calls a healthy chain broken.

Two states the spine cannot carry, and both still read as a broken link on a walk that read no signature, and cross at Strict where the secrets resolve. A segment that compaction has folded into a consolidated row is covered by nothing: its head is adopted rather than derived from its own fields. And a chain whose lifecycle events have all aged out has nothing left to corroborate the replay, which attested_height reports as 0.

What the spine does NOT cover is what has since been done to a segment. The purge stamps are re-signed on every transition, and a transient-purge months after the archive must not move a value the chain has already recorded, so they stay outside the digest. A bridge resting on the spine therefore has the range and the anchors on cryptographic footing and the purge claim on none, which is the same position every other unauthenticated attestation is in, and it is reported the same way rather than quietly upgraded.

It also never excuses a purge claim that was checked and failed. The spine carries a signature a walk could not READ; a lifecycle HMAC that re-derives under a resolvable secret and does not match is a forgery, and a missing secret elsewhere on the same row is no reason to stop reporting it. Only an unreadable signature falls through to the spine.

Nor does it cover a consolidated row's own fields. Such a row records the head of the highest segment it absorbed, and a replay adopts that head rather than deriving it, because the rows it would derive from were deleted by the same transaction. Its own range and anchors are values compaction restated, so editing them moves nothing here. A consolidated row is never reported as spine-covered for that reason, and a bridge over a compacted range goes back to needing its signatures. The segment_compacted event is what accounts for it, and drush audit_trail:reindex-segments compares the two.

What a clean walk proves

  • No row between the genesis row and the chain head has been edited (column tampering would change the recomputed hash, and so would the HMAC layer over that hash).
  • No row has been inserted in the middle (a new row's previous_hash would mismatch the surrounding rows).
  • No row has been removed from the middle of the chain (the row after the deletion would have a previous_hash pointing to a vanished hash). Removal from the END is a different case: see below.

Verification does NOT prove:

  • That a row at the chain head wasn't WRITTEN by a forger who has the secret. (The signature is symmetric; possession of the secret + database write access lets you produce a chain that validates.) Mitigation: external WORM export, RFC 3161 timestamps on batch boundaries: see security.
  • That every audit-worthy action made it into the chain. (A bug in the consumer that silently drops calls, or a code path that reaches neither AuditTrail::record() nor a chained \Drupal::logger() call, is invisible to AuditTrailVerifier.)
  • That the chain still reaches as far as it once did, if the records of how far it reached were removed too. Deleting the most recent rows leaves a shorter chain with no dangling previous_hash anywhere, because there is no row after the deletion. What catches it is the signed checkpoint naming a row that is now missing, with no segment attesting a live-purge over it, and the verifier reports that as broken. A checkpoint is a row in the same database, so an attacker who deletes those as well leaves nothing to compare against. Mitigation: TSA timestamps on chain heads whose response is retained off-box, and a short archive_after so fewer rows sit outside an archive; see the truncation non-goal in threat-model.

Segment-event cross-reference

For each segment_* event the verifier walks (segment_archived, segment_transient_purged, segment_live_purged, segment_file_purged), it confirms a mutual reference with the segment row the event names:

  • The chain event's resource field must be segment:<id>, except segment_created, which carries segment:<from>-<to>: the id it would name is the id of the row carrying it, so the verifier reads it off the row instead.
  • The matching audit_trail_segment.<transition>_event_id column must point back at the event's id.

A mismatch surfaces as segment row may have been rolled back to a pre-transition state in broken_ranges. This catches the canonical rollback tamper: an attacker with DB write access but no operator secret who tries to undo a lifecycle transition by clearing <transition>_event_id back to 0.

Live-purge supersession exemption. Restore is a legitimate reversal of a prior live-purge (see architecture.md). When the verifier sees a segment_live_purged event whose id doesn't match the segment's live_purged_event_id, it does one extra O(1) primary-key lookup on the value the segment points at. The mismatch is accepted only when the referenced event:

  1. Exists in audit_trail (rules out rollback-to-zero).
  2. Lives on the same chain as the current walk (rules out cross-chain pointer forging).
  3. Has resource = 'segment:<same_id>' (rules out pointers at events for a different segment).
  4. Has action segment_live_purged OR segment_restored (rules out pointers at archive / file-purge / unrelated events).
  5. Has an id strictly greater than the event under verification (rules out pointers at an older live-purge, which would let an attacker hide a more recent purge).

Forged segment_restored events can't exploit this exemption at the depths that read signatures, though not because anything gates the lookup: checkRow() returns early on the recomputed hash and on nothing else, so the cross-reference check runs whatever the HMAC check returned. What closes it is that the forged event is itself a row on the chain. Writing one without the operator secret leaves a row whose HMAC does not hold, and the verdict is already FALSE on that row before its compacted_from or its action is consulted.

At VerificationDepth::None the HMAC check does not run, so nothing here distinguishes a forged segment_restored from a real one. That is the stated limit of that depth rather than a gap in the exemption: None reads no signature and has no basis to call anything forged, which is why its verdict names what it accepted without checking instead of vouching for it. A reader who needs the exemption to mean something has to walk at Tolerant or Strict.

Other segment transitions (segment_archived, segment_file_purged, segment_transient_purged) are not reversible by restore, so their strict-equality check stays in force: any rollback tamper on those columns is still surfaced.

Mid-restore transitional states verify cleanly. Restore is not atomic: the segment_restored chain event commits in Step 1 BEFORE the rows are replayed (Step 2) and BEFORE the segment row is updated (Step 3). The verifier accepts every intermediate state without special-casing:

  • Between Step 1 and Step 2: the chain has a segment_restored event referencing the segment, but no rows have re-appeared in [from_id, to_id]. The verifier walks visible rows + the archive bridge as it would for any fully-purged segment; segment_restored is non-cross-checkable so the event passes through. The segment_live_purged event still strict-matches segment.live_purged_event_id (Step 3 has not moved the pointer yet).
  • Between Step 2 and Step 3: rows are now back in the live table. The verifier walks them in id order and validates each row's previous_hash linkage; the archive bridge does not fire because there is no gap. The segment_live_purged event still strict-matches segment.live_purged_event_id.
  • After Step 3: segment.live_purged_event_id now references the segment_restored event id. The strict-equality check on the segment_live_purged event fails; the supersession exemption above accepts the mismatch.

The transitional acceptance is property of the existing rules, not a separate exemption: no rule was added or relaxed to support the narrowed restore design.

A range purged while it did not verify

Retention moves evidence, and both stages that move it check what they are moving first.

At archive. The rows in the segment's range are walked, and a break nothing explains is written into the segment_archived event's permanent bucket and logged as an error. The archive is written anyway: the archive window is a legal deadline, and stalling it until somebody attends to a break would trade a dated obligation for an undated one. Nothing is lost by proceeding, because the rows are still live and the chain still reports the break on every run.

At live-purge. This is the moment the evidence goes, so the range is checked again. A range whose signing secret can no longer be resolved counts as failing here, the way the walk counts it: nobody can authenticate those rows, and that has to be said out loud rather than passed over on the way to deleting them. If the range still does not verify and no acknowledgment covers it, the rows are deleted on schedule and the segment is marked: the fact goes into the segment_live_purged event's permanent bucket, and audit_trail_segment.verification_failed_at_purge is set and signed into lifecycle_hmac beside the purge stamp it belongs to.

The mark is what the walk reads. A marked segment does not bridge the gap silently: the range is reported as purged-and-unexplained and the chain does not read as verified, every run, until somebody explains it. Without it the verdict went green on its own, because a segment's anchors are separate columns from the row content: an edit to a row between them leaves both ends matching, so the bridge fits and the walk resumes as if nothing had happened.

A marked segment is also left out of compaction, which otherwise folds a run of file-purged segments into one row covering all of them. The range is what an acknowledgment anchors to, so widening it would stop an explanation already recorded from covering the segment, and re-open a finding an operator had closed.

Compaction leaves the walk with references to segment rows that no longer exist: every lifecycle event a folded segment emitted still names its id. Those resolve through the segment_compacted event, whose compacted_from payload lists the ids the consolidated row absorbed, and a reference the list covers reads as compaction-superseded rather than as a missing segment. The event is an ordinary chained row, so its own HMAC is checked by the walk that is reading it: a forged compacted_from is a forged row, and the chain verdict is already FALSE before the list is consulted. There is no side table; the chain is the record of what compaction did.

The operator's window is the interval between archive_after and live_purge_after, which needs no setting of its own and on a real site is months. Acknowledging a range resolves its anchors from the live rows, so it works right up until the live-purge deletes them.

Explaining one afterwards. The mark is permanent and is not clearable: "this range was purged while it did not verify" is a fact about what happened, and a fact an operator can delete is worth nothing. The column is only ever set, never recomputed, so a second live-purge after a restore can add it but cannot take it away. What an acknowledgment changes is the verdict. The range goes on reporting as failed and the verdict names the reason somebody gave for it:

before: rows 2-4 were purged and did not verify, unexplained
after:  rows 2-4 were purged and did not verify, explained:
        "secret lost in the 2026 migration"

The same holds for the row just before an acknowledged range. Retention takes ranges oldest first, so that row goes before the range an operator explained does, and its hash is the one the acknowledgment pinned as anchor_before. It is read off the segment that took it, for the same reason and from the same place, so an explanation keeps covering its range as the chain ages out around it.

Such an acknowledgment covers the whole segment, and only the whole segment. Recording one reads two chain hashes, and for a purged range they come off the segment, which holds the same pair: row N's previous_hash IS row N-1's hash, and the segment's copy is signed into its identity HMAC while a live row's is not. That is the finest anchor left once the rows are gone, so excusing one bad row means excusing the range the segment covers. Before the purge an operator can name exactly the rows that are broken; the granularity degrading with time is the incentive to attend to a break while there is still something to attend to.

Running verification periodically

Drupal's own cron already runs the walk when auto-verify is on, and says so when the answer changes: a chain that stops verifying is logged critical on the audit_trail channel, naming the chain and its first broken range, and a chain that verifies again is logged notice. Once per change, not once per tick, because a break stands until it is acknowledged and a line per run would bury the one that mattered. A break a full walk found is still reported by the incremental walks after it, which do not read the rows below their checkpoint, so "verifies again" waits for a full walk that finds the chain clean. A strict full walk run by hand counts: drush audit_trail:verify --full is recorded, and logged, the same way as cron's.

That is deliberately a log entry rather than a notification channel of its own: whatever the site already ships logs with carries it, and sites that want no more than this need configure nothing.

The pattern below is for the rest: an exit code to hang an external monitor on, or a verdict to hand to something like PagerDuty.

The standard pattern is a cron-driven verification job that calls drush audit_trail:verify and alerts (via Drupal watchdog to external monitor, or via email, or via a status report block) on any non-ok result. The drush command exits non-zero when any chain breaks, so a one-liner crontab covers the integration:

0 * * * * drush audit_trail:verify \
          || mail -s "audit_trail alert" admin@example.test

For embedded use (e.g., a custom alerting script that talks to PagerDuty), call the verifier service directly:

$results = \Drupal::service('audit_trail.verifier')->verifyAll();
$bad = array_filter($results, fn ($r) => !$r['ok']);
if ($bad !== []) {
  $msg = "AUDIT_TRAIL INTEGRITY BREAK:\n";
  foreach ($bad as $chain => $r) {
    $msg .= "  - {$chain}: {$r['message']}\n";
  }
  fwrite(STDERR, $msg);
  exit(1);
}
echo "All chains verified.\n";

Performance: incremental verification + checkpoints

A full walk costs O(chain length) in time. Its memory does not follow the chain length: the walk reads the chain in bounded batches rather than in one statement. Core's MySQL driver runs buffered queries, so a single statement over the whole chain hands every row to PHP, both payload columns included, before the first one is examined. A chain large enough to make the walk slow is a chain large enough to make it run out of memory first.

Measured on MySQL, walking a chain of 12,000 rows each carrying a 4 KB payload: the batched walk raised peak memory by 5.5 MB, and reading the same chain in one statement raised it by 63 MB. The batched figure is the size of one batch and stays there; the other grows with the chain, which at a few million rows is tens of gigabytes.

One caveat on "does not follow the chain length": a verdict also accumulates the archive and acknowledgment ranges it crossed, so a chain with a very long retention history carries a little more. Those are per segment, not per row.

The time is what checkpoints are for: fine for hundreds of entries, intolerable for the multi-million-entry chains a long-lived audit-worthy install accumulates. The module keeps verification cheap with per-chain checkpoints:

  • Every time verifyChainIncremental() walks a chain cleanly to its current head, it mints a row in audit_trail_checkpoint recording (chain, last_id, last_hash, created) plus an hmac column signing the tuple.
  • The next call reads the most recent checkpoint for that chain, starts the walk after last_id, expecting last_hash as the genesis-equivalent of previous_hash.

A typical operational pattern:

  • Cron hourly: verifyAll() (default: incremental). Each chain walks only the rows since its last checkpoint: minutes-to-hours of activity, hundreds to thousands of entries at most. Sub-second.
  • Cron weekly (or on-demand): verifyAll(full: TRUE). Full cold walk from genesis to head. Useful as a belt-and-braces check even though incremental walks already validate checkpoint signatures (see below): a full walk re-derives every HMAC from the secret rather than resuming from a checkpoint. It still reads the checkpoint first, because that is the only record of how far the chain once reached: a walk from genesis over a chain whose end was deleted covers rows that are all present and correct, and would otherwise report clean.

Checkpoints are themselves signed with the row's signing secret: each row carries an hmac column over (chain || last_id || last_hash || created), keyed by the secret_id that signed the audit row at last_id. verifyChainIncremental() validates the checkpoint's signature before trusting it; a forged or modified checkpoint fails the check, the verifier falls back to a full walk from genesis, and the result is flagged with checkpoint_forged => TRUE plus a warning in the message so operators can investigate the forgery itself as a security event.

Checkpoints are an optimization for the common case (cron polling), and for everything the chain itself records the chain is the authoritative source: lose all checkpoints and a full walk still verifies every surviving row end-to-end.

They are not only an optimization, though. A checkpoint is the only record of how far a chain once reached, and the rows cannot supply that: deleting the most recent ones leaves a shorter chain that is genuinely self-consistent. So losing every checkpoint for a chain costs the ability to notice that its end was removed. See the truncation non-goal in threat-model.

For chains expected to outgrow what a single-process walk handles even with weekly full verification (multi-million row, multi-year archives), the roadmap plans for chain rotation (yearly closure + fresh chain) and external WORM export + qualified TSA timestamping: at which point old chains live in a write-once archive and are verified once, against their qualified timestamp, rather than re-walked from the DB.

API

$verifier = \Drupal::service('audit_trail.verifier');

// Incremental: fast, default.
$verifier->verifyChainIncremental('notarial');
// returns ['ok' => TRUE, 'count' => 73, 'checkpoint_minted' => TRUE,
//    'message' => 'Chain "notarial" verified incrementally:
//                  73 new entries since the last checkpoint,
//                  at id 4754. Checkpoint refreshed.']

// Checkpoints are minted BY a clean incremental walk, under the
// throttle, the lock and the active-secret check it enforces.
// `mintCheckpoint()` itself is @internal and takes the walked
// last id and hash; production callers go through the walk.

// Full cold walk: slow, on demand.
$verifier->verifyChain('notarial');

// All chains: incremental by default, pass full: TRUE for cold.
$verifier->verifyAll();
$verifier->verifyAll(full: TRUE);

What a broken chain means

If verifyChain returns ok => FALSE, treat it as a security incident:

  • The break could be benign: a developer ran an ad-hoc SQL UPDATE in dev to fix a typo, a database migration altered the table. Verify the timestamp on the broken row against the local change log.
  • The break could be adversarial: someone with database access edited a row to cover an unauthorized action. Treat as a compromise: rotate the master secret, audit other systems, follow the incident response plan for the deployment.

Either way the chain stays usable for new entries from the break onwards (previous_hash of the next row records the broken row's hash so the chain "heals" from there). But every row from the break to the verification point is now considered unverified: annotate the incident in your audit log, preferably in the same chain (with an audit_trail: TRUE event explaining the discrepancy), so the trace stays self-describing.