Database schema
Six publish-only, forward-only migrations (no down(), timestamp-injected when published, never auto-loaded). No package table uses soft deletes and no model is swappable — security state is persisted only through these models and the engine.
| # | Table | Connection | Foreign keys |
|---|---|---|---|
| 1 | sentinel_keys | sentinel.database.connection | — |
| 2 | sentinel_checkpoints | the sealable model’s | — |
| 3 | sentinel_ledger | the sealable model’s | checkpoint_id → sentinel_checkpoints (restrict) |
| 4 | sentinel_seals | the sealable model’s | ledger_entry_id → sentinel_ledger (restrict) |
| 5 | sentinel_idempotency_keys | sentinel.database.connection | — |
| 6 | sentinel_nonces | sentinel.database.connection | — |
Seals, ledger and checkpoints live on each sealable model’s connection and share its transaction. Hosts with sealables on several connections publish and run 0002–0004 per connection and list them in ledger.connections.
Upgrading from a pre-release build
Migration 0004_create_sentinel_seals_table was edited in place to add the nullable attributes_mac column (the MAC of the attribute document, for seals with computed fields); there is no new migration. If you ran an earlier pre-release copy, re-publish the migrations and re-run 0004, or add the column to sentinel_seals yourself — string('attributes_mac', 192)->nullable() — before the next sealed write. Installs from the current migrations already have it.
Models
Read them for reporting (Seal::query()->where('key_id', $kid)->count()), never write them: the engine owns every write, and the MACs, chain and envelopes catch edits. Every package timestamp is cast with Casts\UtcDateTime (UTC, microseconds, CarbonImmutable), so the application’s time zone never leaks into stored or MAC’d times.
| Model | Table | Notes |
|---|---|---|
Key | sentinel_keys | $hidden = ['envelope']; the envelope is AES-256-GCM ciphertext bound to the row’s plain columns, written and opened by the key store. |
Seal | sentinel_seals | sealable(): MorphTo, sealedBy(): MorphTo, ledgerEntry(): BelongsTo; manifest / field_tags → array; event → SealEvent. |
LedgerEntry | sentinel_ledger | No timestamps; append-only (Eloquent updates and deletes throw, quiet ones included); sealable(), actor(), checkpoint(). |
Checkpoint | sentinel_checkpoints | UPDATED_AT = null; append-only. |
IdempotencyKey | sentinel_idempotency_keys | $hidden = ['response', 'owner_token']; status is processing or completed. |
Nonce | sentinel_nonces | UPDATED_AT = null; subject(): MorphTo; kind → NonceKind. |
Factories
- KeyFactory — ->hmac(), ->ed25519(), ->ecdsaP256(), ->verifyOnly(), ->revoked(), ->retired(), ->pending(), ->ring(), all with valid envelopes.
- SealFactory — ->forSealable(Model $m, string $seal), structurally valid but unsigned: for Malformed and negative tests.
- LedgerEntryFactory — ->forSealable(Model $m, string $seal), ->tombstone(SealEvent $event = SealEvent::Deleted); plus CheckpointFactory.
- IdempotencyKeyFactory — ->processing(), ->completed(), ->expired().
- NonceFactory — ->issued(), ->seen(), ->consumed(), ->expired().
Retention
| Table | Grows with | Pruned |
|---|---|---|
sentinel_seals | one row per (model, seal) | deleted with hard-deleted models |
sentinel_ledger | one row per seal event | never (evidence) |
sentinel_checkpoints | one row per checkpoint batch | never |
sentinel_keys | keys | never (retire or revoke instead) |
sentinel_idempotency_keys | idempotent requests | sentinel:prune (expired) |
sentinel_nonces | issued and remembered nonces | sentinel:prune (expired) |
Protecting the tables
Sentinel detects changes to these tables too, but database grants make tampering harder in the first place. The application user needs INSERT and SELECT on sentinel_ledger and sentinel_checkpoints, only UPDATE (checkpoint_id) on the ledger, and the one row-lock privilege each engine asks for, because sentinel:checkpoint locks the checkpoint tail with SELECT … FOR UPDATE. Never DELETE. Keep the anchor store’s credentials away from the database’s.
-- PostgreSQL
GRANT SELECT, INSERT ON sentinel_ledger, sentinel_checkpoints TO app;
GRANT UPDATE (checkpoint_id) ON sentinel_ledger TO app;
GRANT UPDATE (seq) ON sentinel_checkpoints TO app;
-- MySQL
GRANT SELECT, INSERT ON app_db.sentinel_ledger TO 'app'@'%';
GRANT SELECT, INSERT ON app_db.sentinel_checkpoints TO 'app'@'%';
GRANT UPDATE (checkpoint_id) ON app_db.sentinel_ledger TO 'app'@'%';
GRANT LOCK TABLES ON app_db.* TO 'app'@'%';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.