Skip to content

Timeline — Activity Stream & Audit Trail

Granit.Timeline adds a unified activity stream (Odoo-style chatter) to any entity — comments, internal notes, system logs, threaded replies, file attachments, and a follow/notify pattern.

  • DirectoryGranit.Timeline/ Core stream engine, in-memory default, closed-catalog reactions on TimelineEntry
    • Granit.Timeline.EntityFrameworkCore Durable persistence (PostgreSQL)
    • Granit.Timeline.Endpoints Minimal API routes, permissions
    • Granit.Timeline.Notifications Follow/notify via Granit.Notifications
    • Granit.Timeline.Auditing Federated bridge — project audit entries into the stream
    • Granit.Timeline.AI Audit-trail summarization + anomaly detection
PackageRoleDepends on
Granit.TimelineITimelined, ITimelineReader/Writer, ITimelineSource, stream DTOs, closed-catalog reactions (👍 ❤️ 🎉 …) on entriesGranit.Timing
Granit.Timeline.EntityFrameworkCoreDurable persistence for entries + attachments + reactionsGranit.Timeline, Granit.Persistence
Granit.Timeline.EndpointsREST API, permission policiesGranit.Timeline, Granit.Authorization
Granit.Timeline.NotificationsNotification-backed follower/notifier bridgeGranit.Timeline
Granit.Timeline.AuditingFederated bridge — projects Granit.Auditing entries into the stream so audited changes show up on the affected entity’s timelineGranit.Timeline, Granit.Auditing
Granit.Timeline.AISummarize long audit trails into a digest; detect unusual activity patternsGranit.Timeline, Granit.AI
graph TD
    TL[Granit.Timeline] --> T[Granit.Timing]
    TLEF[Granit.Timeline.EntityFrameworkCore] --> TL
    TLEF --> P[Granit.Persistence]
    TLE[Granit.Timeline.Endpoints] --> TL
    TLE --> A[Granit.Authorization]
    TLN[Granit.Timeline.Notifications] --> TL

[DependsOn(typeof(GranitTimelineModule))]
public class AppModule : GranitModule { }

In-memory stores for entries and followers. No database required.

Add the ITimelined marker interface to any entity that should have an activity stream:

public class Invoice : FullAuditedEntity, ITimelined
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public InvoiceStatus Status { get; set; }
}

The entity type name is derived from the CLR type name by convention ("Invoice"), or can be overridden with [Timelined("custom-name")].

public class InvoiceApprovalHandler(ITimelineWriter timeline)
{
public async Task HandleAsync(
InvoiceApproved evt, CancellationToken cancellationToken)
{
// System log — immutable, cannot be deleted (ISO 27001)
await timeline.PostEntryAsync(
"Invoice",
evt.InvoiceId.ToString(),
TimelineEntryType.SystemLog,
$"Invoice approved by **{evt.ApprovedBy}** for amount {evt.Amount:C}.",
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

Entry bodies support Markdown formatting. The @mention syntax (@[Display Name](user:guid)) triggers one-time mention notifications (capped at 10 per entry) when the Endpoints module processes the entry.

public sealed record TimelineStreamEntry
{
public required Guid Id { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public required TimelineStreamEntryType EntryType { get; init; }
public string? AuthorId { get; init; }
public string? AuthorName { get; init; }
public string Body { get; init; } = string.Empty;
public IReadOnlyList<TimelineAttachmentInfo> Attachments { get; init; } = [];
public Guid? ParentEntryId { get; init; }
// Federation
public TimelineEntryOrigin Origin { get; init; } = TimelineEntryOrigin.Native;
public string SourceKey { get; init; } = TimelineSourceKeys.Native;
public string? SourceId { get; init; }
// Edit tracking
public DateTimeOffset? EditedAt { get; init; }
}

Origin discriminates entries that live in the native timeline_entries table (Native) from those projected by an ITimelineSource contributor (External). SourceKey carries the contributor name ("auditing", "workflow", …) — soft contract, contributors register their own. Reactions, replies, and edits only target Native rows; an External entry must first be anchored to materialize a native shadow row (see below).

Entry typeDescriptionDeletableVisibility
CommentHuman-authored comment visible to all usersYes (soft-delete, GDPR)All users with Timeline.Entries.Read
InternalNoteStaff-only internal noteYes (soft-delete, GDPR)Requires Timeline.InternalNotes.Read
SystemLogAuto-generated audit entryNo (immutable, ISO 27001)All users with Timeline.Entries.Read

All endpoints are prefixed with /api/{version}/timeline.

MethodPathDescriptionPermission
GET/{entityType}/{entityId}Paginated activity stream (newest first), merged across registered ITimelineSource contributorsTimeline.Entries.Read
POST/{entityType}/{entityId}/entriesPost a comment, note, or system logTimeline.Entries.Create
PATCH/{entityType}/{entityId}/entries/{entryId}Edit own comment/note within TimelineOptions.EditWindow (default 15 min)Timeline.Entries.Create + ownership
POST/{entityType}/{entityId}/anchorMaterialize a native shadow row for an external entry (idempotent) so reactions/replies can target itTimeline.Entries.Create
DELETE/{entityType}/{entityId}/entries/{entryId}Soft-delete an entry (GDPR) — owner or adminTimeline.Entries.Create + ownership or Timeline.Entries.Manage
POST/{entityType}/{entityId}/followSubscribe current user as followerTimeline.Followers.Manage
DELETE/{entityType}/{entityId}/followUnsubscribe current userTimeline.Followers.Manage
GET/{entityType}/{entityId}/followersList follower user IDsTimeline.Entries.Read

The GET stream endpoint sets the X-Timeline-Degraded-Sources response header (comma-separated source keys) when one or more contributors timed out or threw under the default DegradeGracefully policy. Beyond TimelineOptions.MaxPage (default 10) the endpoint returns 400 timeline-depth-exceeded so a misbehaving client can’t deepen federated pagination indefinitely.

Any module can contribute entries to the unified stream by implementing ITimelineSource. The reader merges native rows with every registered contributor under a canonical sort (OccurredAt DESC, native wins ties, SourceKey ASC, Id ASC) and applies per-source timeouts.

public interface ITimelineSource
{
string SourceKey { get; } // regex ^[a-z][a-z0-9_-]{0,63}$
Task<IReadOnlyList<TimelineStreamEntry>> GetEntriesAsync(
string entityType, string entityId, int limit, CancellationToken ct);
Task<TimelineStreamEntry?> GetEntryAsync(
string entityType, string entityId, string sourceId, CancellationToken ct);
}

The framework ships Granit.Timeline.Auditing — register the module and every AuditEntry targeting an ITimelined entity shows up in that entity’s timeline as an External-origin SystemLog:

[DependsOn(
typeof(GranitTimelineEntityFrameworkCoreModule),
typeof(GranitTimelineAuditingModule))]
public class AppModule : GranitModule { }
sequenceDiagram
    participant Client
    participant Reader as ITimelineReader
    participant Native as timeline_entries
    participant Audit as IAuditingReader

    Client->>Reader: GET /timeline/User/u-42
    par fetch native top-K
        Reader->>Native: SELECT ... ORDER BY CreatedAt DESC LIMIT K
    and fan-out to sources
        Reader->>Audit: GetByEntityAsync("User", "u-42", limit=K)
    end
    Reader->>Reader: merge, dedupe shadows, sort canonical
    Reader-->>Client: TimelineStreamResult { Page, DegradedSources }

Anchor — making external entries interactive

Section titled “Anchor — making external entries interactive”

Reactions and threaded replies always target a native TimelineEntry.Id. To attach interaction to an External-origin entry, the client first calls POST /timeline/{entityType}/{entityId}/anchor with the source key + source id. The server materializes a shadow row with a deterministic v5 GUID derived from (TenantId, EntityType, EntityId, SourceKey, SourceId) and returns it:

POST /api/v1/timeline/User/u-42/anchor
{ "sourceKey": "auditing", "sourceId": "9c40…" }
200 OK
{ "entryId": "e2c4…" }

Anchoring is idempotent: the deterministic Id means concurrent callers converge on the same row. The shadow snapshots the source’s body, author, and OccurredAt at anchor time — if the audit log later purges its row, the shadow keeps the snapshot so reactions remain meaningful (audit retention independence).

PATCH /timeline/{entityType}/{entityId}/entries/{entryId} lets the author revise the body of a comment or internal note within a configurable window. The writer enforces four gates server-side; the front-end mirrors them to hide the affordance but the writer is the source of truth.

canEdit = entry.origin === "Native"
&& entry.entryType !== "SystemLog"
&& entry.authorId === currentUser.id
&& (now - entry.occurredAt) < TimelineOptions.EditWindow
Gate failureReason returned in extensions["reason"]
Shadow row or live external projectionExternalOrigin
Native SystemLogSystemLog
Caller is not the authorNotAuthor
TimelineOptions.EditWindow elapsed (or set to TimeSpan.Zero)WindowExpired

Successful edits set TimelineEntry.EditedAt (surfaced as EditedAt on TimelineStreamEntry) and raise TimelineEntryEditedEvent — useful for invalidating AI summary caches or replicating into the audit trail. No permission separate from Timeline.Entries.Create is required; ownership + window do the gating. A moderator who wants to “correct” someone else’s comment soft-deletes and reposts under their own name (chain-of-custody clean).

public sealed class TimelineOptions
{
public int MaxPage { get; set; } = 10;
public TimeSpan SourceTimeout { get; set; } = TimeSpan.FromSeconds(2);
public SourceFailurePolicy OnSourceFailure { get; set; }
= SourceFailurePolicy.DegradeGracefully;
public TimeSpan EditWindow { get; set; } = TimeSpan.FromMinutes(15);
}
OptionDefaultPurpose
MaxPage10Federated pagination depth cap. Beyond it, GET returns 400 timeline-depth-exceeded.
SourceTimeout2 sPer-source timeout. Slower sources are dropped (Degrade) or rethrown (Throw).
OnSourceFailureDegradeGracefullyDrop + report via X-Timeline-Degraded-Sources. Set ThrowAll in dev/test to fail loud.
EditWindow15 minTimeSpan.Zero disables edit entirely.
PermissionDescription
Timeline.Entries.ReadView activity streams and audit history
Timeline.Entries.CreatePost comments and internal notes
Timeline.Entries.ManageAdmin: soft-delete any entry regardless of ownership
Timeline.InternalNotes.ReadView staff-only internal notes (filtered by default)
Timeline.Followers.ManageFollow/unfollow entities
sequenceDiagram
    participant U as User A
    participant API as Timeline API
    participant FS as FollowerService
    participant N as Notifier

    U->>API: POST /Invoice/42/entries (body: "@[Bob](user:guid) reviewed")
    API->>API: PostEntryAsync (persist)
    API->>FS: GetFollowerIdsAsync() → ["user-a"]
    API->>N: NotifyEntryPostedAsync (fan-out to followers)
    API->>N: NotifyMentionedUsersAsync (one-time notification for @Bob)

Without Granit.Timeline.Notifications, the follower service uses an in-memory store and the notifier is a no-op (NullTimelineNotifier). Installing the Notifications package replaces both with notification-backed implementations that leverage Granit.Notifications for multi-channel delivery (email, push, SignalR).

Timeline entries support file attachments via ITimelineWriter.AddAttachmentAsync(), referencing blob IDs managed by Granit.BlobStorage:

await timeline.AddAttachmentAsync(
entryId,
blobId: uploadResult.BlobId,
fileName: "scan.pdf",
contentType: "application/pdf",
sizeBytes: 245_000,
cancellationToken).ConfigureAwait(false);
CategoryKey typesPackage
ModulesGranitTimelineModule, GranitTimelineEntityFrameworkCoreModule, GranitTimelineEndpointsModule, GranitTimelineNotificationsModule, GranitTimelineAuditingModuleTimeline
Timeline coreITimelined, ITimelineReader, ITimelineWriter, ITimelineSource, ITimelineFollowerService, ITimelineNotifierGranit.Timeline
FederationTimelineStreamResult, TimelineEntryOrigin, TimelineSourceKeys, TimelineDepthExceededExceptionGranit.Timeline
EditTimelineEntryNotEditableException, TimelineEntryNotEditableReason, TimelineEntryEditedEventGranit.Timeline
OptionsTimelineOptions { MaxPage, SourceTimeout, OnSourceFailure, EditWindow }Granit.Timeline
DTOsTimelineStreamEntry, TimelineStreamEntryType, TimelineAttachmentInfo, PostTimelineEntryRequest, UpdateTimelineEntryBodyRequest, AnchorTimelineEntryRequest/ResponseGranit.Timeline*
PermissionsTimelinePermissions.Entries.{Read,Create,Manage}, .InternalNotes.Read, .Followers.ManageGranit.Timeline.Endpoints
ExtensionsAddGranitTimeline(), AddGranitTimelineEntityFrameworkCore(), AddGranitTimelineAuditing(), MapGranitTimeline()