Tracking what changed in a IIIF Manifest: Object-graph deltas in .NET
How change tracking can help reduce serialization bandwidth, be an audit trail, enable selective downstream processing, and accumulate event history
A IIIF Manifest can be a quite voluminous object graph. A real Manifest may contain hundreds or thousands of Canvases, nested Annotations, image services, descriptive metadata, structures, or extension resources. But an editorial operation may only touch a single Canvas title, its rights information, or image dimensions. The naive approach to persistence is to serialize the whole Manifest again anyway. However, the question that often arises in practice is: What changed?
This is the sort of question that leads to the change-tracking subsystem in IIIF Manifest Serializer for .NET. I’ve created a separate proof of concept to demonstrate this, because the behavior really is worth seeing:
IIIF.POC.ChangeTrackingLab:
https://github.com/KiarashMinoo/IIIF.POC.ChangeTrackingLab
The core SDK is at:
https://github.com/KiarashMinoo/IIIF.Manifest.Serializer.Net
As of this writing, the POC is an ASP.NET Core Razor Pages app that targets .NET 10 and consumes the SDK from NuGet, version 3.0.13.
This is object-graph change tracking, not CDC from a database
Database change data capture (CDC) runs after the fact, on the database or transaction log. The IIIF SDK change tracker runs earlier, on the in-memory object graph.
It can answer questions like:
- Has this Manifest changed?
- Which property has changed?
- What was the value before?
- What is the value now?
- Was a child added or removed?
- Has something changed within a Canvas?
- How do I get from the Manifest to that Canvas?
A database is not required. The POC deliberately does not include PostgreSQL, TimescaleDB, EF Core, or anything else that might make it look like a database-centric change-tracking system. It only stores event history in memory to demonstrate the principle.
That separation is important, because the change-tracker is an SDK feature, not a storage system.
The public model is small
All trackable objects in the SDK carry a similar set of properties:
bool HasChanges { get; }
IReadOnlyCollection GetChanges();
void ClearChanges();
void AcceptChanges();In other words, everything in the object graph participates in change tracking: Manifests, Collections, Canvases, Ranges, AnnotationPages, Annotations, content resources, services, extensions, etc. There is no EF Core magic going on here.
This is meant to feel familiar to anyone who has worked with an object-relational mapper (ORM): you get change tracking, but it is up to the application to save changes somewhere.
The pull-based design is deliberate
One reason the change tracker in the SDK is interesting is that it is
pull-based, rather than push-based. A conventional object graph may bubble changes upwards:
Canvas changes
↓
notify parent Manifest
↓
mark Manifest dirtyThis seems simple, but it breaks when objects are added, removed, moved, reused, or kept in sibling collections. A push-based approach usually requires wiring:
Add child
→ subscribe
Remove child
→ unsubscribe
Move child
→ unsubscribe old parent
→ subscribe new parentMissing any of those leads to missed events. The change tracker in the SDK does not use this approach. Instead, HasChanges and GetChanges() traverse the object graph at the time of inspection:
var canvas = new Canvas(
"https://example.org/canvas/1",
new Label("Page 1"),
1000,
800);
manifest.AddItem(canvas);
manifest.ClearChanges();
canvas.SetHeight(2000);
Console.WriteLine(manifest.HasChanges); // trueThe parent does not need to know about the Canvas. When the Manifest is inspected, the graph is traversed to find the change, and a reference-equality visited set is used to avoid recursion. The downside is that this inspection is deferred until serialization time, which can be too late for some applications. However, for most real-world scenarios where changes are persisted, published, or synchronized, this trade-off is acceptable.
Baselines are explicit
The lifetime of a change-tracked object is usually simple and predictable. A newly constructed model begins with a baseline, rather than having constructor arguments treated as edits. Likewise, a deserialized manifest is now dirty, and should be saved. After changes are examined and applied, the current graph becomes a new baseline:
manifest.AcceptChanges();In the current SDK, AcceptChanges() and ClearChanges() are equivalent. This provides the following useful application-level pattern:
Load
↓
clean baseline
↓
edit
↓
inspect changes
↓
do application work
↓
AcceptChanges
↓
new baselineThe proof of concept explicitly demonstrates this pattern.
HasChanges is only the beginning
A dirty flag is great, but a delta is more interesting:
var changes = manifest.GetChanges();Each change entry contains:
Path
Kind
PropertyName
OriginalValue
CurrentValue
ChangedAtUtcA nested Canvas edit might look like this:
Path: Items[0].Height
Kind: Modified
PropertyName: Height
OriginalValue: 1200
CurrentValue: 1300Rather than bubbling up “something changed” events, the change tracking system provides enough information to perform downstream actions based on specific leaf-node edits.
This can be used to:
- invalidate caches;
- update indices and databases;
- provide an audit trail;
- publish webhooks or events;
- synchronize with other systems;
- analyze change patterns over time.
Structural and value collections are tracked differently
Certain collections participate as first-class citizens in the IIIF graph, namely Manifest.Items and Manifest.Structures. Edits to such collections generate CollectionItemAdded and CollectionItemRemoved change entries.
Child objects that stay within their parent collection can still report their own changes:
Canvas.Label has changedAs opposed to treating these collections as simple value types, which would require Label modified entries when items are added or removed.
This distinction keeps the change tracking meaningful at the domain layer.
The two delta views serve different purposes
The SDK offers two methods for delta serialization:
manifest.GetChangedManifest(); // partial Manifest with only changed values
manifest.GetChangeSet(); // change envelope with complete informationThese should not be used interchangeably.
`GetChangedManifest()` generates a compact current-state view
`GetChangedManifest()` attempts to generate a best-effort partial Manifest, containing the values that can be represented in a IIIF Manifest. There is also a `SerializeChangedOnly()` convenience method:
var changedJson = IiifSerializer.SerializeChangedOnly(manifest);In the proof of concept, this output is compared to the fully serialized manifest, and the two are displayed alongside their UTF-8 byte counts:
Full Manifest: N bytes
Changed-only output: M bytes
Payload reduction: X%The actual percentage depends on the edits and the structure of the Manifest. The POC calculates it at runtime for demonstration purposes, rather than using a hard-coded value.
`GetChangedManifest()` is useful for reducing bandwidth when only a subset of the data has changed, but the actual benefit must be evaluated for each application.
`GetChangeSet()` provides a complete change envelope
For applications that need to process changes, this is the preferred method:
var changeSet = manifest.GetChangeSet();A ChangeSet contains:
RootId
RootType
CreatedAtUtc
Changes
ChangedManifestWhere `Changes` is the collection of `IiifChangeEntry` discussed earlier.
This is the recommended way to inspect change tracking data for downstream processing.
Why a deleted Canvas is not visible in the changed Manifest
A valid IIIF Manifest always describes the current state. If a Canvas has been removed, the Manifest has no property that indicates that it used to be there:
Canvas X used to be here but has now been deleted.There is no tombstone inside the Manifest shape. Therefore, it is not possible to represent a deletion as a `ChangedManifest`. The change envelope can:
Path: Items[2]
Kind: CollectionItemRemoved
OriginalValue:
CurrentValue: nullThe POC draws special attention to this scenario, making this case easy to see. For historical change tracking, the `GetChangeSet().Changes` is the preferred approach for removals.
What the proof of concept actually does
The POC is intentionally small. It is an ASP.NET Core Razor Pages app, rather than a full-fledged viewer or persistence system. Each browser session gets its own isolated state on the server. It provides a number of controls to make small edits to a sample Manifest, along with inspectors to examine the change tracking data:
- Change rights information
- Change height of first Canvas
- Rename second Canvas
- Add a Canvas
- Remove a Canvas
- Inspect `HasChanges`;
- Inspect pending change entries;
- Compare full vs changed-only JSON;
- Compare their UTF-8 sizes;
- Inspect a simulated append-only event history;
- Commit the current ChangeSet;
- Call `AcceptChanges()` and establish a new baseline;
- Reset the demo.
You can find the full source code at:
https://github.com/KiarashMinoo/IIIF.POC.ChangeTrackingLab
Event history is deliberately in-memory
The POC is not a database, and is only using in-memory state with server-side session affinity for simplicity and isolation.
The implementation uses:
- ASP.NET Core Session to identify the browser;
- `IMemoryCache` to store the actual demo state;
- a 20 minute idle timeout;
- read-through/write-through synchronization for the session;
- an in-memory list to represent the event log.
In other words, the timeline only exists for the duration of the session.
This is intentional. The change tracking system is an SDK feature: it deliberately avoids choosing a particular persistence strategy, and leaves that decision to the application.
What happens when the POC “commits” a delta
When the commit button is clicked, the POC simulates passing a change envelope to another service:
var changeSet = manifest.GetChangeSet();
var revision = ++state.Revision;
foreach (var change in changeSet.Changes)
{
eventLog.Add(ProjectToStableEvent(change, revision));
}
manifest.AcceptChanges();There are two reasons why this code is worth discussing.
A revision belongs to the application layer
The change tracker knows what changed, but it does not dictate how changes are persisted. It does not know your database, distributed event system, or even what your database’s transaction isolation level is. The POC simply adds a `Revision` counter to the change envelope. This is the correct approach for most applications.
Values are snapshotted
`OriginalValue` and `CurrentValue` can carry complex objects.
A durable event should not reference in-memory objects. The POC serializes these values to JSON before they are snapshotted as part of the envelope. This small detail becomes important when integrating with real-world systems.
There are two timestamps in the POC
The POC’s event model deliberately tracks two timestamps:
DetectedAtUtc
PersistedAtUtcBecause the change tracking system is pull-based, a change entry is only materialized when someone calls `HasChanges` or `GetChanges()`. Therefore, this timestamp should not be mistaken for “the time when the change happened”. A real application is likely to have additional timestamps:
occurred_at
detected_at
persisted_at
published_atThe POC only persists events when the commit button is explicitly clicked. The timestamp reflects that action.
Use cases for this pattern
With change tracking, there are many ways to take advantage of the information beyond just calling `HasChanges()` or serializing a delta.
Audit trails
Instead of storing the current Manifest only, an application can display a timeline of changes made to it. The change envelope can be used to construct a UI similar to this:
Revision 42 Items[1].Label "Page 2"
→
"Map of Muscat, 1910"This is much more informative to an editor than comparing two large JSON documents.
Cache invalidation
A cache key can be generated based on the changed path. For example:
Items[0].HeightThis allows an application to perform cache invalidation based on the change, rather than blindly invalidating everything. The change tracking system does not implement caching, but it offers the required hooks if a cache key is needed.
Search indexing
A search pipeline can be optimized based on the changes. `Items[4].Label`, `Summary`, `Metadata`, or `Label` fields may be worth reindexing, whereas other changes may be ignored.
Webhook delivery
A webhook receiver does not need the entire Manifest to be sent for every notification. An application can choose to project the change envelope into a suitable format for downstream consumption. For example:
{
"eventId": "…",
"manifestId": "https://example.org/manifest/1",
"revision": 42,
"changes": [{
"path": "Items[0].Height",
"kind": "Modified",
"original": 1200,
"current": 1300
}]
}This can be useful when working with Kafka, RabbitMQ, Azure Service Bus, AWS SNS/SQS, NATS, webhooks, or your own event bus. In a real application, the envelope would also contain additional context like event IDs, correlation IDs, causation IDs, actors, tenants, or timestamps.
Synchronization and replication
Change envelopes can reduce the amount of data transferred when replicating between systems. The sender only needs to send the changes since the last known revision. The receiver can apply those changes to its own copy of the Manifest. In a production environment, a synchronization protocol would also handle the ordering of events, idempotency, retries, concurrency, and snapshot restoration. This is all orthogonal to the change tracking system itself.
Why time-series storage is interesting
I am not using TimescaleDB, but it is worth noting that this is an interesting architecture for time-series storage. There is nothing particularly special about the format of a change envelope. It should be possible to store it in any database that can handle JSON.
A projected event has natural dimensions:
manifest_id
revision
path
kind
original_value
current_value
persisted_atIn other words, it is an event with a timestamp, along with additional tags or attributes. TimescaleDB hypertables are PostgreSQL tables with built-in partitioning that understand time. For time-series storage, this provides a good balance between performance and query flexibility.
For manifests that receive frequent edits, this can be used to ask questions like:
- How many Manifest changes happened per hour?;
- What are the most common property changes?;
- What collections see the most activity?;
- How often are Canvases being added or removed?;
- Does a new import pipeline correlate with increased metadata churn?;
- What was the change rate before and after a release?
There is more information about hypertables in the Timescale documentation:
https://docs.tigerdata.com/use-timescale/latest/hypertables/
Continuous aggregates are good for change analytics
If you have a time-series database, you may want to ask questions about your change data at a higher level. For example:
changes per hour
changes per Manifest
removals per day
top changed propertiesTimescaleDB continuous aggregates can help with this.
More information is available in the documentation:
https://docs.tigerdata.com/use-timescale/latest/continuous-aggregates/
Timescale is only one option, as PostgreSQL may be sufficient.
I also want to store snapshots
I would not recommend trying to reconstruct every live Manifest from an unlimited change stream. For most applications, a hybrid approach is better: a canonical manifest view with append-only change tracking.
This gives the best of both worlds: fast current-state queries, plus the ability to inspect historical changes with a rich event model. This is an architectural pattern that is discussed here, rather than implemented in the POC.
Stable IDs are more important than array indexes
A change path like this is perfectly valid:
Items[4].LabelThis indicates that the Label for the fifth item in the Items collection has changed. The problem with this approach is that `Items[4]` is a position in an array, rather than a stable identifier. If Canvases are reordered, that index now points to a different resource. For durable change tracking, I would also store the resource ID for that Canvas.
The `path_at_revision` would still contain the array index to indicate the structure, but `affected_resource_id` would point to the IIIF resource that was modified.
Change tracking is not JSON Patch
The SDK paths are object-model paths:
Items[0].HeightThey are not JSON Pointer, JSONPath, or RFC 6902 JSON Patch
The change tracking system tracks domain changes within this SDK.
An adapter could later be written to represent appropriate changes as an external patch format, if needed.
Change tracking and IIIF Change Discovery are different
IIIF has a standard Change Discovery API for discovering that IIIF resources have changed. The change tracking discussed here is designed to work with that specification at a lower level.
The change tracking system might know that:
Items[0].Height 1200 → 1300…but a IIIF Change Discovery feed would communicate at the resource level that the Manifest was updated.
These can work together: the internal change tracking helps to determine that a meaningful resource update has happened, and can be used to power an external IIIF Change Discovery feed.
More information is available in the IIIF API specification:
https://iiif.io/api/
Full manifest serialization is still meaningful
Change tracking is not meant to replace manifest serialization:
IiifSerializer.Serialize(manifest);It only helps when an application needs to know what changed.
IiifSerializer.SerializeChangedOnly(manifest);is a separate convenience method, which is why it has such a long name. It demonstrates that the change tracking system can be used as an incremental improvement: an application can first use `HasChanges`, then optionally audit change history, then send change envelopes for downstream processing, without disrupting the existing manifest serialization.
When to use this approach
I would seriously consider adopting this approach for any IIIF application that has:
- large manifests;
- frequent small edits;
- editorial processes;
- auditing requirements;
- downstream indexers or caches;
- event-driven architectures;
- webhooks;
- synchronization needs;
- operational analytics over content changes.
For a smaller application with rare edits, full document persistence may still be the best option.
The goal is not to make every IIIF application event-sourced. The goal is to give the application enough information to make an informed choice.
Known limitations
The boundaries are as important as the value:
- `GetChangedManifest()` is a best-effort current view, not a general-purpose patch document;
- removals must be accessed via `GetChangeSet().Changes`;
- the SDK is not implementing a database;
- the POC is not implementing a database;
- the POC event timeline is in-memory and ephemeral;
- the SDK is not providing application-level revisions and distributed concurrency;
- change entries are not JSON Patch operations;
- the graph traversal is optimized for inspection, not mutation;
- positional paths should be supplemented with stable resource IDs for long-running applications;
- complex values should preferably be projected or serialized for durable storage;
- change timestamps should have explicit application semantics rather than implying exact mutation time.
Final observations
The value of change tracking is immediately apparent from this feature:
manifest.HasChangesBut the real value appears once an application can answer questions like these:
what changed?
where did it change?
what was there before?
what is there now?
was something added?
was something removed?
what should I do because of that change?This information can reduce bandwidth, improve auditing, invalidate caches, update indices, publish events, synchronize systems, or analyze patterns in the change data. The change tracking system provides this data at the domain layer: the complete manifest is still the canonical source of truth, but `GetChanges()` can enable a more expressive application than ever before.
Resources
Proof of concept: https://github.com/KiarashMinoo/IIIF.POC.ChangeTrackingLab
Get Ahmad Minoo’s stories in your inbox
Join Medium for free to get updates from this writer.
The SDK: https://github.com/KiarashMinoo/IIIF.Manifest.Serializer.Net
NuGet: https://www.nuget.org/packages/IIIF.Manifest.Serializer.Net
Change tracking docs: https://github.com/KiarashMinoo/IIIF.Manifest.Serializer.Net/blob/main/docs/CHANGE_TRACKING.md
IIIF specs: https://iiif.io/api/
Time-series storage:
- Timescale hypertables docs: https://docs.tigerdata.com/use-timescale/latest/hypertables/
- Timescale continuous aggregates docs: https://docs.tigerdata.com/use-timescale/latest/continuous-aggregates/








