Ledger, checkpoints and anchors
A seal alone proves that a row matches a seal. The ledger proves it matches the latest one: every seal event appends an entry to sentinel_ledger, MAC’d over its audit fields, and the scheduled sentinel:checkpoint folds new entries into chained, keyed checkpoints and publishes the newest one to the configured anchors.
Sentinel::ledger()->checkpoint(); // CheckpointResult or null
$report = Sentinel::ledger()->verify(); // LedgerReport
$report->clean();
$report->findings; // list<LedgerFinding>
Sentinel::ledger()->history($invoice); // list<LedgerRecord>
Sentinel::ledger()->head(); // the newest checkpoint
Sentinel::ledger()->anchors(); // ['cache']Ledger entries
- Appended in the same transaction as the seal event — sealed, resealed, acknowledged, baseline, rotated, deleted, unsealed — unique per (model, seal, version), with contiguous versions that survive delete and re-create.
- Each entry has its own MAC over identity, version, event, key, the seal MAC, the previous digest, the changed attribute names, the previous status, the actor and the reason. Editing any audit field is EntryInvalid.
- Append-only: updating or deleting a LedgerEntry or a Checkpoint through Eloquent throws LedgerIsAppendOnlyException — with model events muted too.
- Never pruned — it is evidence. Turning it off (SENTINEL_LEDGER=false) loses replay and rollback detection.
Checkpoints
sentinel:checkpoint (every minute by default) locks the newest checkpoint, selects up to a batch of unclaimed entries in id order, chains them from the previous root, inserts the checkpoint with its own MAC (signed by ledger.ring’s current key) and claims the entries — their ids inlined as integers rather than bound, so a batch of up to 100 000 entries stays below every engine’s bind-parameter limit. Entries that commit late join a later checkpoint; concurrent runs and deadlock victims are retried. LedgerCheckpointed fires after commit and the checkpoint is published to the anchors — after the commit too: a checkpoint made inside an open transaction reaches the anchors only once that transaction commits, never when it rolls back (CheckpointResult::$anchors is then empty). With nothing pending, a run republishes the newest checkpoint to the anchors that hold an older one or nothing at all (a failed first publication, a flushed cache) — never to a write-only anchor.
use RoundlyConsulting\Sentinel\DataTransferObjects\CheckpointOptions;
use RoundlyConsulting\Sentinel\DataTransferObjects\LedgerVerifyOptions;
Sentinel::checkpoint(new CheckpointOptions(connection: null, batchSize: 500)); // one batch; null = nothing pending
Sentinel::verifyLedger(new LedgerVerifyOptions(entities: false)); // skip the per-entity head scan
Sentinel::verifyLedger(new LedgerVerifyOptions(manualAnchor: $payload)); // compare an AnchorPayload you kept
Sentinel::ledgerHead(); // ?CheckpointRecord, unverified
Sentinel::anchors(); // the configured anchor namesPer-model history
$entries = Sentinel::ledger()->history($invoice, 'financial', limit: 20); // list<LedgerRecord>, newest first
$entries = Sentinel::for($invoice)->history(); // the handle's seal
$entries = Sentinel::ledgerHistory($invoice); // flat formLedgerRecord has id, event, version, ring, keyId, changed, previousStatus, actorType, actorId, reason, occurredAt and checkpointId (null until checkpointed). Records are read as stored — run verifyLedger() to check them.
Anchors
An anchor stores the newest checkpoint outside the database. Without one, restoring the whole database (ledger and checkpoints included) to an older snapshot, or deleting the checkpoint tail, is undetectable.
| Driver | Stores | Reads back |
|---|---|---|
cache | put('{key}:{connection}', payload) forever in anchor_drivers.cache.store — a Redis that is not the application database. | yes |
filesystem | {path}/{connection}/{seq}.json (written once) + latest.json on anchor_drivers.filesystem.disk — object storage with object lock or versioning is ideal. | yes |
log | Log::channel()->info('sentinel.anchor', payload) — a WriteOnlyAnchor, never re-sent. | no — write-only; compare with sentinel:verify --ledger --anchor='<json>' |
| custom | Sentinel::extendAnchor() | your choice |
SENTINEL_ANCHORS=cache,filesystem
SENTINEL_ANCHOR_CACHE_STORE=anchor-redis
SENTINEL_ANCHOR_DISK=s3-lockedThe payload (sentinel.anchor/1: connection, seq, root, at, ring, keyId, algorithm, mac) carries the checkpoint MAC, so a tampered anchor is detected; a forged anchor that claims to be ahead only raises a false alarm. Publishing happens after commit, best effort: a failure fires AnchorPublishFailed, is logged, and the next run republishes to the anchors that lag behind. A store that reports a failed write by returning false instead of throwing (a disk with throw off — Laravel’s default — or the null cache store) counts as a failure too (AnchorPublishException). Custom anchors are covered under Extending.
An anchor keeps the evidence
A checkpoint never overwrites an anchor with one that does not extend what it holds. Before publishing, each anchor’s latest() is compared with the database: when the database no longer has the anchored seq (a restore) or has it with another root, the publication is refused — published false, AnchorPublishFailed with an AnchorPublishException, a log warning — and the anchor keeps its copy, so sentinel:verify --ledger goes on reporting anchor_ahead (or anchor_mismatch once the database has a different checkpoint at that seq). Every later run refuses again until someone investigates. After a deliberate restore, check what happened, then clear the anchor (delete latest.json or the cache key) so the next checkpoint publishes afresh. An anchor that already holds this checkpoint, or a newer one of the same chain, counts as published and is left alone.
Verifying the ledger
$report = Sentinel::ledger()->verify(); // every connection, entity heads included
$report = Sentinel::ledger()->verify('tenant', entities: false, chunk: 5000);
$report->checkpoints; // checkpoints verified
$report->entries; // ledger entries verified
$report->anchorsChecked; // anchors compared
$report->findings; // list<LedgerFinding>
$report->clean();
$report->violations(); // findings except backlog and unreachable anchors
$report->has(LedgerFindingKind::AnchorAhead);
$report->throwIfViolated(); // LedgerIntegrityExceptionPer connection in ledger.connections, verification checks:
- Checkpoints in seq order: contiguous (CheckpointGap), chained (ChainBroken), MAC’d by a usable ledger-ring key (CheckpointInvalid), and each root recomputed over its entries with every entry MAC checked (EntryInvalid, CheckpointMismatch).
- Anchors: unreachable (AnchorUnreachable), invalid MAC or key (AnchorInvalid), ahead of the database (AnchorAhead), a different root at the same seq (AnchorMismatch). The anchored seq is looked up in the database when it is compared, so a checkpoint committed while a long verification runs is no false AnchorAhead.
- Pending entries: their MACs, a Backlog when entries older than ledger.backlog_warning_seconds are still unanchored, and OrphanEntry for an entry whose checkpoint_id names no checkpoint.
- Entity heads (with entities): every model whose latest entry is not a tombstone must still exist (EntityDeleted) with its seal row (SealMissing) at that version (SealRolledBack).
Every finding except Backlog and AnchorUnreachable is a violation and fires LedgerIntegrityViolated. Ledger evidence signed by a key that was later retired still verifies; revoked, pending and unknown keys never do.
Several connections
Seals, ledger and checkpoints live on each sealable model’s connection. With sealables on several connections, run migrations 0002–0004 on each and list them:
'ledger' => ['connections' => [null, 'tenant_archive']],Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.