-
Notifications
You must be signed in to change notification settings - Fork 2
Specs M6 Incidents Incident Timeline Entry
incidents.models.timeline.IncidentTimelineEntry
The chronology of an Incident : one dated, attributed, append-only entry per thing that happened. This is the single narrative an auditor, a supervisory authority or a court reads. It is the source of the GDPR Art. 33(3)(a) "facts relating to the personal data breach", of the NIS2 Art. 23(4)(d) final-report narrative, and of the sequence-of-events section of the incident register export.
File : incidents/models/timeline.py
Not a BaseModel, not a ScopedModel, and not a ReferenceGeneratorMixin : a plain models.Model with its own UUID primary key, explicitly declared row timestamps, a version counter and a django-simple-history audit trail. The reasons are set out in Why this is not a BaseModel and they are load-bearing : changing any of them changes what the register is worth in front of a regulator.
No lifecycle. A BaseModel runs a registered lifecycle, and _ensure_initial_step() fires only on a blank or unknown workflow_state, so every ordinary insert lands in draft; an explicitly assigned domain step would stick, but it would leave no core.LifecycleEvent row, which is why the pattern is banned; the snap targets to that lifecycle's step. A narrative entry has no states : "10:42 EDR isolated WEB-PRD-02" is never draft, never pending, never validated. Giving it a lifecycle would put a governance workflow around a sentence, and would make the chronology filterable by a step that carries no meaning. The entity therefore has no workflow_state column at all, _ensure_initial_step() never runs against it, and it is deliberately invisible to reportable(), linkable() and deletable_states(). It is read through its parent incident, always.
Never edited. An account of an incident that can be rewritten is not evidence. save() refuses any write against an existing primary key and delete() refuses outright, both raising core.lifecycle.LifecycleProtectedError (the house exception, the same one BaseModel.delete() raises when a lifecycle state forbids deletion). Corrections are appended, never applied in place : see Correction by supersession.
Never deleted. No delete view, no DELETE route, no MCP delete tool, no admin delete action. The entity is create-and-read only on every one of the three surfaces.
No reference prefix. ReferenceGeneratorMixin._generate_next_reference() (context/models/base.py) selects every existing reference sharing the prefix, pulls the whole list into Python and takes the maximum, on every single insert. That is acceptable for a few hundred suppliers or risks; it is the wrong shape for a log that takes a row on every lifecycle transition and every responder note during a live incident, where a major incident alone can produce hundreds of entries in an afternoon. Entries are cited by their occurred_at and their position in the incident file, not by a business reference, so the prefix buys nothing and costs a full-table scan per write. Entries are addressed by UUID everywhere : in the API, in MCP payloads and in the superseded_entry self-relation.
Cheap to write. A responder types an entry mid-incident. The write path is a single INSERT with no reference scan, no lifecycle resolution, no scope M2M and no transition bookkeeping.
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
UUID | PK, default=uuid.uuid4, editable=False
|
Unique identifier. Matches the BaseModel convention without inheriting it. |
incident |
relation | FK -> Incident, required, CASCADE, related_name="timeline_entries"
|
The incident being narrated. |
occurred_at |
datetime | required, indexed | Real-world time of the act being narrated. May be backdated. This is the ordering key : the chronology reads in the order things happened, not in the order they were typed. |
recorded_at |
datetime | auto_now_add=True |
When the entry was written. occurred_at != recorded_at is normal during a live incident and is itself evidence of the response tempo. |
entry_type |
enum | required, default observation
|
Nature of the entry. See Enums. |
summary |
string | required, non-blank, max 500 | The one-line entry, e.g. EDR isolated WEB-PRD-02. Rendered in the chronology card and exported verbatim. |
detail |
text | optional, HTML rich text, blank default | The full account : commands run, output observed, people spoken to. |
source |
enum | required, default manual
|
Who wrote the row : a human, the lifecycle engine, a background job or a bulk import. See Enums. |
author |
relation | FK -> User, required, PROTECT, related_name="incident_timeline_entries"
|
Who wrote the entry. PROTECT so a user who wrote incident history can never be hard-deleted : the account must stay attributable. Deactivate or anonymise the user instead. |
related_action |
relation | FK -> IncidentResponseAction, optional, SET_NULL, related_name="timeline_entries"
|
The operational action this entry narrates, when there is one. |
related_evidence |
relation | FK -> IncidentEvidence, optional, SET_NULL, related_name="timeline_entries"
|
The evidence item this entry narrates, when there is one. |
superseded_entry |
relation | FK -> self, optional, SET_NULL, related_name="corrections"
|
The earlier entry this one corrects. Set only on entry_type=correction. |
correction_reason |
text | required non-blank when superseded_entry is set, blank default |
Why the earlier entry is being corrected. A correction with no stated reason is a rewrite. |
is_evidence |
boolean | default False
|
Marks the entry for verbatim inclusion in generated regulatory filings and in the incident register export. |
version |
int |
PositiveIntegerField, default 1
|
Row version counter, mirroring the SupplierSubprocessor precedent for non-BaseModel audit rows. Never incremented in practice, since the row is never updated; kept so the shape matches the platform's other child rows and so a non-1 value is itself a signal. |
created_at / updated_at
|
datetime |
auto_now_add / auto_now
|
Row timestamps. Declared explicitly because BaseModel is not inherited. |
history |
HistoricalRecords() |
Tamper detection. See Append-only : what is actually guaranteed. |
Meta.ordering = ["incident", "occurred_at", "recorded_at"]. The three-key ordering is deliberate : two entries can legitimately share an occurred_at (two responders narrating the same minute), and recorded_at then breaks the tie deterministically so the exported narrative is stable between two renders of the same incident file.
Reproduced verbatim from incidents/constants.py (DB value = Label).
TimelineEntryType :
| Value | Label |
|---|---|
observation |
Observation |
action |
Action |
decision |
Decision |
communication |
Communication |
escalation |
Escalation |
evidence |
Evidence |
external_input |
External input |
correction |
Correction |
system |
System |
TimelineEntrySource :
| Value | Label |
|---|---|
manual |
Manual |
lifecycle |
Lifecycle transition |
system |
System |
import |
Import |
The module states this plainly rather than claiming an immutability the schema does not provide.
Prevention is at application level. save() raises LifecycleProtectedError when self._state.adding is false, and delete() raises LifecycleProtectedError unconditionally. Every documented write path in Cairn goes through Model.save(), so the web forms, the DRF serializers, the MCP tools and the Django admin are all covered.
What bypasses it :
-
QuerySet.update()andQuerySet.bulk_update()issue SQL without callingsave(). -
QuerySet.delete()and cascade deletion do not callModel.delete(). A cascade from the parent incident therefore removes entries without the guard firing. In practice this only ever reaches adraftincident, because RG-INC-07 makes an incident undeletable fromdetectedonward, and a draft incident has no narrative worth losing; but the mechanism is stated here so nobody discovers it during an audit. - Raw SQL, a
manage.py shellsession and direct database access bypass Python entirely.
Detection is via HistoricalRecords. Every ORM-level write that does go through save() leaves a historical row, and django-simple-history records the acting user. An entry whose historical trail shows more than one row has been altered, and that is visible on the entry's history panel and in the merged timeline built by core.history.build_timeline. The honest claim to make to an auditor is therefore: tampering with the chronology is prevented on every supported path and detectable on the rest, not the chronology is immutable.
Real database-level immutability would need PostgreSQL rules or triggers, which core.settings_test (SQLite in memory, migrations disabled) cannot exercise. That divergence is not taken in this module; if it is ever taken, it must be taken deliberately and documented here.
A factual error in the chronology is fixed by appending, never by editing :
- Create a new entry with
entry_type = correction. - Point
superseded_entryat the entry being corrected. - Give a non-blank
correction_reason. - Set
occurred_atto the real-world time of the fact being restated, not to the time of the correction. The chronology then still reads in the order things happened, andrecorded_atreveals how long the error stood uncorrected.
The superseded entry is never modified and never hidden. The chronology card renders it with a struck-through summary and a link to its correction, and the correction renders with a back-link. A correction may itself be corrected : the chain is followed to its end.
Exports follow the chain. is_evidence entries that have been superseded are exported as the latest non-superseded version of the fact, with the superseded original kept in the appendix, so a regulatory filing never quotes a statement the organisation has since retracted while still showing that the retraction happened.
RG-INC-09. Every lifecycle transition on the parent Incident automatically appends one entry with source = lifecycle, carrying the transition label as the summary, the acting user as author, the transition comment as detail and the transition time as occurred_at. This is what keeps the narrative and the state machine from diverging : an incident cannot move from contained to eradicated without the chronology saying so, and a reader of the chronology alone can reconstruct the whole process.
The append lives in the Incident.transition_to() override (RG-INC-08), which is the one place that binds all three surfaces : the web stepper (core/workflow_views.py), the DRF LifecycleAPIMixin and the MCP transition tool all funnel through BaseModel.transition_to. It runs inside the transition's transaction, so a rolled-back transition leaves no entry and a committed transition always leaves exactly one.
Ordering with auto-created sibling rows. Two incident transitions create governed sibling rows : detected -> triaged instantiates the IncidentNotification obligations, and recovered -> post_incident_review creates the PostIncidentReview. Neither row may be created directly in its domain step : _ensure_initial_step() fires only on a blank or unknown workflow_state, so an ordinary insert lands in draft, and an explicitly assigned domain step would stick but would leave no core.LifecycleEvent row. A PostIncidentReview(...) saved with workflow_state="scheduled" would hold that step with no recorded entry into the register. Each auto-creation path must therefore save() the row and then call transition_to("scheduled" | "assessed", user, enforce_permission=False) in the same transaction. The lifecycle timeline entry describing the creation is written after that transition has completed, so the narrative never claims a row is in a step it has not actually reached.
Where a hole can appear. Any future code path that assigns workflow_state directly instead of calling transition_to(), and any bulk import that writes incident rows without replaying their transitions, produces a chronology with a gap while the lifecycle history stays complete. There is no way to detect that from the timeline alone. The module therefore ships a reconciliation check (see below) and forbids direct workflow_state assignment in review.
An incident carries three record sets that describe overlapping facts. They are not redundant, they answer different questions, and reconciling them at audit time is real work. This table is what the docs owe the auditor.
| Trail | What it records | Written by | Authoritative for |
|---|---|---|---|
core.LifecycleEvent |
One immutable row per performed transition : lifecycle_name, from_step, to_step, actor, comment, timestamp, plus the cleaned data of any per-transition form. Generic (content type), so it covers every lifecycle-bearing entity in the module. |
BaseModel.transition_to() only. |
The process. Which state the incident was in, from when to when, who moved it and on what stated grounds. This is the record of whether the documented procedure was followed, and it is the one to cite for a permission or approval question. |
HistoricalRecords (django-simple-history) |
A full row snapshot per save(), with the acting user and the change reason, on every entity in the module including this one. |
Every save(), including saves that write no narrative. |
The data. What a given field held at a given instant, and whether it changed outside the documented flow. This is the tamper-detection trail and the only one that can expose an edit nobody narrated. |
IncidentTimelineEntry |
Free-text narrative, one entry per act, occurred_at distinct from recorded_at, attributed to a named author, correctable only by supersession. |
Responders, plus the transition override (source=lifecycle) and background jobs (source=system). |
The facts. What actually happened in the world, in real-world order, in words. This is the account a regulator or a court reads, and the source of the GDPR Art. 33(3)(a) description. It is the only trail that can be backdated, which is exactly why it is append-only and attributed. |
Reading rules, applied in this order when the three disagree :
- A question about state ("was it contained before the notification went out?") is answered by
LifecycleEvent. The timeline can be backdated; the lifecycle history cannot. - A question about a field value ("when did severity become critical?") is answered by
HistoricalRecords. The narrative may summarise a change loosely; the historical row is exact. - A question about the world ("what did the responder see at 10:42?") is answered by the timeline. Neither of the other two records observations.
- A
source=lifecycletimeline entry with no matchingLifecycleEvent, or aLifecycleEventon an incident with no matching entry, is a defect and is reported as one. The incident register export includes a reconciliation line stating whether the counts match, so a hole is visible on the document the auditor is holding rather than discovered by interview.
core.history.build_timeline already merges LifecycleEvent and HistoricalRecords into the generic history panel; the chronology card is rendered separately and deliberately, because mixing a narrative written for humans into a diff feed written for machines makes both unreadable.
RG-INC-38. IncidentTimelineEntry is not a ScopedModel and never carries its own scopes. It inherits the parent incident's scope through scope_parent_lookup = "incident__scopes", so it can never drift out of alignment when the incident is re-scoped.
Phase 1 must extend three call sites for that inheritance to be real, because scope inheritance for non-ScopedModel children is not currently enforced everywhere :
-
mcp/tools.py_filter_by_scopes()handlescontext.Scopeand a directscopesM2M, then returns the queryset unfiltered. Aparent_lookupparameter is added and threaded through_register_crud/_list_handler/_get_handler, otherwiselist_incident_timeline_entriesreturns every entry on the instance to any holder ofincidents.incident.read. -
core/workflow_views.pyguards withhasattr(obj, "scopes"), so a non-scoped child is reachable cross-scope. The guard is extended to honour a model-levelscope_parent_lookup. (This entity exposes no transition endpoint, but its siblings do, and the fix is one guard.) -
core/history_views.pycarries the samehasattr(obj, "scopes")guard, so the full history of a timeline entry is otherwise readable cross-scope.
These three changes are core work in the phase-1 PR, not an incidents-app detail, and they are logged under a ### Security entry in CHANGELOG.md.
| ID | Rule |
|---|---|
| RG-INC-09 | Every lifecycle transition on an Incident automatically appends an IncidentTimelineEntry with source=lifecycle, carrying the transition label, the actor and the comment, so the narrative and the state machine can never diverge. |
| RG-INC-10 | The incident chronology is append-only. save() refuses any update to an existing row and delete() refuses outright, both raising LifecycleProtectedError; no update or delete route exists on the web, API or MCP surfaces. A correction is a NEW entry of type correction pointing at superseded_entry with a non-blank correction_reason. This is a Python-level guarantee : QuerySet.update(), bulk_update(), cascade deletion and raw SQL bypass it, and HistoricalRecords therefore makes tampering DETECTABLE, not impossible. The module docs state this to the auditor rather than claiming an immutability the schema does not provide. |
| RG-INC-16 | Reopening a closed incident requires approve and a mandatory comment, clears closed_at, and appends a timeline entry. The original closure remains in the lifecycle history. |
| RG-INC-38 | Scope tenancy : the chronology is never independently scoped and inherits the incident's scope through scope_parent_lookup="incident__scopes" on the web, API and MCP surfaces. |
Base path /api/v1/incidents/, router registration timeline-entries. The viewset is create, list and retrieve only : http_method_names is restricted so no PUT, PATCH or DELETE route is generated at all, matching the append-only rule at the routing layer rather than only in the serializer.
-
GET /api/v1/incidents/timeline-entries/: list, filtersincident,entry_type,source,is_evidence,occurred_after,occurred_before. -
POST /api/v1/incidents/timeline-entries/(+POST .../batch/viaBatchCreateMixin, max 100 items, non-atomic, per-item{index, status, id}). -
GET /api/v1/incidents/timeline-entries/<uuid>/. -
GET /api/v1/incidents/timeline-entries/<uuid>/history/viaHistoryAPIMixin.
Viewset stack : BatchCreateMixin, ScopeFilterAPIMixin (with scope_parent_lookup = "incident__scopes"), HistoryAPIMixin, CreatedByMixin, viewsets.ModelViewSet. LifecycleAPIMixin is not mixed in : the entity runs no lifecycle, so no transition/ route exists. Permissions use ModulePermission with permission_module = "incidents" and an explicit permission_feature, following the newest module precedent (trust_center/api/views.py) rather than another app's ModulePermission subclass. recorded_at, created_at, updated_at, version and author are read-only; author is stamped from the request user.
-
create_incident_timeline_entry: append one entry to an incident's chronology. Requiresincidents.incident.create. -
list_incident_timeline_entries: read the chronology, filtersincident_id,entry_type,source,is_evidence. Requiresincidents.incident.read. Scope-filtered throughincident__scopes.
There is no update and no delete tool, deliberately : the entity is create-and-read only on every surface, and an agent must not be able to rewrite an incident narrative. entry_type and source carry explicit enum lists in field_overrides, and detail is declared with _html_field().
Gated by the parent incident's codenames : reading the chronology needs incidents.incident.read, appending needs incidents.incident.create. There is no incidents.timeline_entry feature, and there will never be one : RG-INC-39 caps the module at exactly six features (incident, security_event, evidence, notification, review, response_plan), each with the five standard actions, so the six SYSTEM_GROUPS suffix filters grant the module unchanged and the group matrix screen renders every codename.
Because the entity is append-only, the incidents.incident.update, .delete and .approve actions have no meaning here : no route consumes them.
Rendered as the Chronology card in the left column of the incident detail page (strict 2-column layout, no nav-tabs, per the platform's detail-page doctrine) :
- Entries ordered by
occurred_atascending, so the card reads top to bottom as the incident unfolded. - Each row shows
occurred_at, the entry-type icon (Bootstrap Icons only), the author avatar and the summary, withdetailin a Bootstrap collapse. -
source = lifecycleentries are visually distinguished from hand-written ones : a muted background and a system icon, so a reader can tell instantly which parts of the narrative the machine wrote. -
recorded_atis shown as a relative hint next tooccurred_atwhenever the two differ by more than a few minutes, since the delay is itself meaningful. - Superseded entries render struck-through with a link to their correction; corrections render with a back-link and the
correction_reasonalways visible, never collapsed. - An Add entry form sits inline at the foot of the card and posts over HTMX into the
#timeline-entriespartial, so a responder never leaves the incident page mid-incident. The form has no edit or delete affordance anywhere. -
is_evidenceentries carry a small marker indicating they will be quoted verbatim in regulatory filings.
The card must render correctly in light and dark mode and on mobile widths; the inline add form in particular is checked at small widths alongside the incident page's other sticky elements.
Every user-facing string is wrapped and given a French translation in locale/fr/LC_MESSAGES/django.po. Several of this entity's labels collide with msgid values that already exist in the catalogue, and a duplicate (msgctxt, msgid) pair makes manage.py compilemessages fail, which the CI workflow runs before pytest. The colliding labels here are Observation, Action, Decision, Evidence, System, Manual and Import.
Those seven are declared with pgettext_lazy("incident", ...) in incidents/constants.py and rendered with {% trans "..." context "incident" %} in templates, and the .po file carries a matching msgctxt "incident" block for each. Communication, Escalation, External input, Correction and Lifecycle transition are new msgid values and are added bare.
The lifecycle_from_json trap does not apply to this entity : it re-wraps stored step labels with bare gettext_lazy after the post_migrate round-trip through LifecycleDefinition, so a step label's msgctxt is lost. This entity declares no lifecycle and no steps, so its labels are plain field choices that never make that round trip. The trap does apply to its parent Incident, whose step labels include the colliding Draft, Closed and Archived : see that file.
- ISO/IEC 27001:2022 A.5.26 (response to information security incidents) : the response must be recorded.
- ISO/IEC 27035-2 (guidelines to plan and prepare for incident response) : the incident chronology as a response artefact.
- GDPR Art. 33(3)(a) : the notification must describe the nature of the breach and the facts relating to it. The
is_evidenceentries are that description's source. - NIS2 Art. 23(4)(d) : the final report's detailed description of the incident, its severity and its impact.
- Incident : the parent, its lifecycle, and the transition override that auto-appends entries.
-
IncidentResponseAction : the typed operational steps entries can point at through
related_action. - IncidentEvidence and EvidenceCustodyEvent : the A.5.28 evidence register and its own append-only ledger, which follows the same prevention-plus-detection framing.
- README.md : module business rules, permission codenames, scope inheritance and the phase plan.
-
governance/workflow.md and governance/lifecycle.md : the lifecycle framework the parent plugs into, and
LifecycleEvent. -
governance/history.md :
HistoricalRecordsand the merged history timeline.
Built from docs/ at v0.36.0. Edits made here are overwritten by the next release : open a pull request against the source instead.
- Administration
- Ask Cairn
- Assets and suppliers
- Compliance
- The dashboard
- Finding your way
- Getting started
- Incidents
- How records move
- Organisational context
- Reports and management review
- Risks
- Trust Center
- Architecture
- Configuration
- Contributing
- The documentation system
- Installation
- Internationalisation
- Operations
- Release process
- Security
- Testing
- Adding an assistant provider
- Adding a dashboard widget
- Adding a domain entity
- Declaring a lifecycle
- Adding an MCP tool
- Adding a REST endpoint
- Adding a report
- Interface conventions
- Dashboard widgets
- Lifecycles
- MCP tools
- MCP tool parameters : Assets
- MCP tool parameters : Compliance
- MCP tool parameters : Governance and context
- MCP tool parameters : General
- MCP tool parameters : Incidents
- MCP tool parameters : Reports and management review
- MCP tool parameters : Risks
- MCP tool parameters : System and administration
- MCP tool parameters : Trust Center
- Management commands
- Models
- Permissions
- REST endpoints
- Environment variables
- MCP server
- REST API
- Assistant module (Ask Cairn)
- Module 0: User Management and Access Control
- Module 1: Context and Organization
- Module 2: Asset Management
- Module 3: Compliance
- Module 4: Risk Management
- Module 4 bis - EBIOS Risk Manager
- Module 5 : Trust Center
- Module 6 : Security Incident Management
- Management review : ISO 27001:2022 compliance (clause 9.3)