Skip to main content

Extension Architecture

Chronicle's extension system lets you inject behaviour into the entry pipeline before Chronicle canonicalizes, hashes, and persists an entry. All built-in validators, context resolvers, and policies are implemented as extensions using exactly the same API available to application code.

Pipeline order

Chronicle::record()->...->commit()

RunExtensions ← your extensions run here

CanonicalizePayload

HashPayload

ChainHashEntry

PersistEntry ← EntryRecorded fires here (sync path only)

Extensions run before canonicalization. They can read and mutate the raw entry attributes. Once CanonicalizePayload runs, the payload is frozen into a deterministic JSON structure and hashed — mutations after that point have no effect.

Extension stages

Extensions declare which stage they belong to via stage(): ExtensionStage. Chronicle runs all extensions in stage order, then by priority within a stage, then by class name, then by registration order.

enum ExtensionStage: int
{
case VALIDATE = 100;
case RESOLVE_CONTEXT = 200;
case POLICY = 300;
case PROCESS = 400;
}
StageValuePurpose
VALIDATE100Reject invalid entries early — throw to abort
RESOLVE_CONTEXT200Enrich the context attribute with runtime data
POLICY300Enforce business rules — throw to reject
PROCESS400General processing / enrichment

The EntryExtension contract

interface EntryExtension
{
public function stage(): ExtensionStage;
public function process(PendingEntry $entry): PendingEntry;
}

process() receives the mutable PendingEntry and must return it (modified or unchanged). Throw any exception to abort the entry — a ChronicleException subclass is conventional for validation/policy rejections, which also triggers EntryRejected.

Ordering within a stage: PrioritizedEntryExtension

For deterministic ordering within a stage, implement the optional PrioritizedEntryExtension contract:

interface PrioritizedEntryExtension
{
public function priority(): int; // lower values execute first
}

Extensions that do not implement this interface are treated as priority 0. Ties within the same priority are resolved by class name (alphabetical), then registration order.

Working with PendingEntry

PendingEntry holds the raw entry attributes while they pass through the pipeline:

// Read an attribute
$action = $entry->attribute('action'); // returns mixed
$meta = $entry->attribute('metadata', []); // with default

// Write an attribute
$entry->setAttribute('metadata', array_merge(
$entry->attribute('metadata', []),
['tenant_id' => $tenantId],
));

Available at extension time (before canonicalization):

  • id, actor_type, actor_id, action, subject_type, subject_id
  • metadata, context, diff, tags, correlation_id, created_at

payload, payload_hash, chain_hash, and checkpoint_id are set by later pipeline stages and are not available to extensions.

Registration

Add the class name to config/chronicle.php:

'extensions' => [
// built-in validators...
App\Chronicle\ResolveTenantContext::class,
App\Chronicle\EnforceTenantPolicy::class,
],

Extensions are resolved through the service container, so constructor injection works normally.

Via Chronicle::extendEntry()

Register at runtime from a service provider:

use Chronicle\Facades\Chronicle;

Chronicle::extendEntry(new App\Chronicle\ResolveTenantContext);
Chronicle::extendEntry(App\Chronicle\EnforceTenantPolicy::class);

Both a class name string and a pre-built instance are accepted.

Extension types

What you wantHow to implement
Reject invalid entriesCustom Validators
Attach runtime dataCustom Context Resolvers
Enforce business rulesCustom Policies
Custom storage backendCustom Storage Drivers
Custom signing/cryptoCustom Signing Providers
Custom actor/subject resolutionCustom Reference Resolvers
React after persistenceListening to Events

See also