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. 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.
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 + latest.json on anchor_drivers.filesystem.disk — object storage with object lock or versioning is ideal. | yes |
log | Log::channel()->info('sentinel.anchor', payload). | 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. Custom anchors are covered under Extending.
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).
- 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.