1.9.1
What's Changed
Added
- VerificationFailure enum centralises all verification failure code strings. Static analysis can now catch typos in failure code comparisons.
Changed
- Breaking: LedgerQuery::paginate() renamed to LedgerQuery::cursorPaginate(). Update any call to Chronicle::query()->paginate() → Chronicle::query()->cursorPaginate().
- Breaking: chronicle.prune.default_retention_days now defaults to null (was 365). Running chronicle:prune with no arguments no longer silently deletes entries older than one year — an explicit retention policy must be configured. Set CHRONICLE_RETENTION_DAYS=365 to restore the previous behaviour.
- Breaking: The export signature now covers a canonical JSON payload containing all manifest fields instead of dataset_hash alone. Existing signature.json files produced by previous versions will fail verification — re-export to regenerate.
- Chronicle UI default middleware changed from ['web', 'auth'] to ['web', 'auth', 'can:view-chronicle']. The gate must be defined in your application. Set chronicle.ui.middleware back to ['web', 'auth'] to restore the previous permissive default.
- IntegrityVerifier::verify() $onProgress callback signature changes from callable(int $processed, int $total) to callable(int $processed) — the pre-flight COUNT(*) query has been removed.
- ChronicleUiController middleware is now declared on the route group in routes/ui.php instead of the constructor ($this->middleware() was deprecated in Laravel 11).
- ChronicleUiController::stats() now delegates entirely to LedgerStats::compute(), eliminating duplicated query logic.
- chronicle:install no longer calls exec() to open a browser tab — the repo URL is printed to the console instead.
- HasChronicle now declares $chronicleEvents and $chronicleIgnore as trait properties with defaults, removing property_exists() duck-typing. Ignored-field detection now uses static::CREATED_AT / static::UPDATED_AT.
- Diff-building logic extracted to ModelDiffBuilder::build() and shared by HasChronicle and ChronicleModelObserver, eliminating drift-prone duplicate implementations.
- ChronicleModelObserver now exposes protected array $ignoredFields = [] for subclasses to add fields beyond the default ['created_at', 'updated_at'].
- Policy classes (AllowedActionsPolicy, ForbiddenActionsPolicy, RateLimitPolicy, ContextPolicy) now read config values once in the constructor rather than on every enforce() call.
- Export file names (entries.ndjson, manifest.json, signature.json) are now defined as constants in ExportFormat.
- RequestContextResolver now receives Illuminate\Http\Request via constructor injection instead of pulling from the global container.
- PruneCommand consolidates three identical query constructions into a single buildPruneQuery() helper.
- ExportVerifier::decodeJsonFile() renamed to tryDecodeJsonFile() to make the "string return means failure code" contract explicit.
- src/README.md removed.
- Deleted ChronicleServiceProvider::assertSigningConfiguration() — no callers; enforcement is already in registerSigning().
Deprecated
- EntryBuilder::modelChanges() now emits a E_USER_DEPRECATED notice and is marked for removal in v2.0. Use modelDiff() instead.
Fixed
- PersistChronicleEntryJob was reading chronicle.database.connection (non-existent key) instead of chronicle.connection. On apps with a dedicated Chronicle DB connection, the job silently wrote to the wrong database, corrupting the chain.
- Ed25519SigningProvider::__destruct() called sodium_memzero() without a null guard — if construction threw before assigning the key, the destructor produced a fatal TypeError at GC time.
- Chronicle::fake() leaked ArrayDriver across tests in the same process. ChronicleAssertions::restore() is now available to clear the binding; ChronicleManager::resetDriver() is exposed as @internal for the same purpose.
- enforce_on_boot = false now correctly allows the app to boot without signing keys. A NullSigningProvider wraps the original exception so the root cause is preserved.
- ExportManager no longer re-hashes the export file after writing. The dataset hash is computed inline during the write pass by EntryExporter, closing a TOCTOU window.
- Export directory is now created with mode 0700 (owner-only) instead of 0755.
- All hash equality checks in the verification layer now use hash_equals() to prevent timing side-channel attacks.
- ChainHashEntry now asserts it is running inside an open database transaction, throwing LogicException if not — preventing silent chain-fork bugs.
- Chain hash creation and verification now order by id only (ULID). The previous created_at + id ordering could select a different predecessor when two rows shared an identical timestamp, producing false chain_hash_mismatch errors.
- LedgerQuery::stream() and LedgerQuery::first() now apply the same default ORDER BY id ASC as get() and cursorPaginate().
- chronicle:prune --before= now prints a human-readable error and exits non-zero instead of throwing an uncaught stack trace.
- chronicle:install now honours --migrate and --no-interaction flags in non-TTY environments.
- publishMigrations() now uses a fixed base date (2026-01-01) for migration timestamps, making repeated installations produce deterministic file names.
- ChronicleServiceProvider now validates the configured signing provider implements SigningProvider before instantiation, preventing arbitrary class construction from .env values.
- ChronicleUiController::show() validates the $id parameter as a ULID before use, returning HTTP 404 for invalid values.
- CanonicalPayloadSerializer::normalize() now handles Stringable objects, backed enums (cast to value), and unit enums (cast to name). Non-serialisable objects throw UnexpectedValueException.
- VerifyEntryCommand no longer uses assert() (disabled in production) to guard against a null entry.
- CheckpointCreator now uses a strict === null check for the chain hash — a corrupt row with chain_hash = '0' no longer falsely triggers the "ledger is empty" error.
- ExportVerifier now skips blank lines consistently for both dataset hash and chain verification, fixing false dataset_hash_mismatch failures on exports with a trailing newline.
- ChronicleAssertions now calls $this->driver->allEntries() instead of ArrayDriver::all() (static), making the constructor parameter functional for test isolation.
- LedgerStats::compute() — dailyActivity() now honours the full requested range when a $from bound is supplied (previously applied a hardcoded 30-day lower bound).
- LedgerStats::compute() — checkpointCount() now respects $from/$to bounds (previously always returned the total count across all time).
- RequestContextResolver now redacts sensitive parameters from URL fragments in addition to query strings, preventing OAuth/OIDC tokens from appearing in the audit log.
- RateLimitPolicy now uses an atomic increment-then-check pattern to prevent concurrent requests from briefly exceeding the configured rate limit.
- SerializesEntryAttributes now stores a null diff as SQL NULL instead of the JSON string "null", making WHERE diff IS NULL queries work correctly.
- DefaultReferenceResolver now throws a clear InvalidArgumentException for unsaved Eloquent models instead of silently producing a reference with a null ID.
- TagsValidator now rejects tags containing non-printable or non-ASCII characters, preventing Unicode homoglyph attacks from bypassing tag-uniqueness checks.
- DatabaseDriver and ChainHashEntry now correctly fall back to the default DB connection when chronicle.connection is missing or empty.
- Stat controller null-dereference on ->count when dailyActivity has no entry for a given day — changed to ?->count ?? 0.
- CanonicalPayloadSerializer::isAssoc() now correctly classifies empty arrays as sequential, matching json_encode behaviour.
- ComplianceReport::generate() no longer constructs ComplianceReportResult twice; removed @ error-suppression from file write.
- PayloadSerializableValidator now uses JSON_THROW_ON_ERROR per project convention.
- ComplianceReport::collectStats() removed misleading @var string annotations on nullable locals.
Full Changelog: 1.9.0...1.9.1