-
Notifications
You must be signed in to change notification settings - Fork 2
Specs M6 Incidents
Version: 1.0 Date: 27 August 2026 Status: Draft
Django app: incidents/.
| Entity | Importable path | Reference prefix | Lifecycle |
|---|---|---|---|
| IncidentResponsePlan | incidents.models.response_plan.IncidentResponsePlan |
IRPL |
core default
|
| SecurityEvent | incidents.models.security_event.SecurityEvent |
EVNT |
security_event |
| Incident | incidents.models.incident.Incident |
INCD |
incident |
| IncidentTimelineEntry | incidents.models.timeline.IncidentTimelineEntry |
none (append-only log) | none |
| IncidentResponseAction | incidents.models.response_action.IncidentResponseAction |
IRAC |
none (plain status) |
| IncidentEvidence | incidents.models.evidence.IncidentEvidence |
EVID |
incident_evidence |
| EvidenceCustodyEvent | incidents.models.evidence.EvidenceCustodyEvent |
none (append-only ledger) | none |
| PostIncidentReview | incidents.models.post_incident_review.PostIncidentReview |
PIRV |
post_incident_review |
| IncidentNotification | incidents.models.notification.IncidentNotification |
INOT |
incident_notification |
| ReportingAuthority | incidents.models.reporting_authority.ReportingAuthority |
RGAU |
core default
|
| ReportingObligationTemplate | incidents.models.reporting_obligation_template.ReportingObligationTemplate |
ROBT |
core default
|
| PersonalDataBreach | incidents.models.personal_data_breach.PersonalDataBreach |
PDBR |
personal_data_breach |
| NotificationFiling | incidents.models.filing.NotificationFiling |
NFIL |
none |
INCDis one letter-order away fromINDC(Indicator). The two are visually confusable in a reference string, in a list column and in the MCP help block. Read them twice.
The Security Incident Management module is the ISO/IEC 27001:2022 Annex A.5.24 to A.5.28 and A.6.8 surface of the platform, and the operational half of clause 10.1 / 10.2. Its structure is not an arbitrary decomposition : it is the five-phase process of ISO/IEC 27035-1 and 27035-2, one phase per group of entities.
| ISO/IEC 27035 phase | Entities | What the phase produces |
|---|---|---|
| Plan and prepare | IncidentResponsePlan, ReportingAuthority, ReportingObligationTemplate | The documented procedure in force, the classification scale that gives severity its meaning, the reporting channels including the anonymous one, and the filing contacts and legal rules prepared before anything happens (A.5.24, A.5.5). |
| Detect and report | SecurityEvent | The A.6.8 register of every reported occurrence, with reported_at - detected_at as the measurable reporting delay and an anonymous channel the database, not a form, guarantees. |
| Assess and decide | the security_event lifecycle |
The A.5.25 judgement : a named person, a written assessment_notes, a stamped assessed_at, and three mutually exclusive permissioned outcomes plus an approve-gated discard. The events that were correctly not incidents leave a trace, which is the question every auditor asks. |
| Respond | Incident, IncidentResponseAction, IncidentTimelineEntry, IncidentEvidence, EvidenceCustodyEvent, IncidentNotification, PersonalDataBreach, NotificationFiling | A.5.26 response with write-once phase stamps, A.5.28 evidence with its chain of custody, and the regulatory obligation register with its legal clock (GDPR Art. 33 / 34, NIS2 Art. 23, DORA Art. 19). |
| Learn |
PostIncidentReview, and through it compliance.Finding and compliance.ComplianceActionPlan
|
A.5.27 learning, and the bridge into clause 10.2 : root cause, similar-occurrence check, corrective actions, and the effectiveness review that closes 10.2 d). |
Two properties are load-bearing across all five phases and are repeated in every entity file rather than assumed :
-
A decision is a lifecycle transition, never a field write. Promoting an event, ruling out a notification obligation, sealing evidence, confirming a personal data breach and closing an incident are all permissioned, comment-bearing transitions that leave an immutable
core.LifecycleEvent. A boolean column cannot carry a decider, a timestamp, a rationale and an approval, and those four together are what an inspection actually reads. -
The module never claims immutability the schema does not provide. The append-only entities prevent rewriting at application level and make tampering detectable through
HistoricalRecords. That is the honest claim, and it is the one made to the auditor.
- The incident management procedure of record, with its plan-testing evidence produced by real exercises run through the real lifecycle.
- The event and weakness register (A.6.8) and the assessment that decides what is an incident (A.5.25).
- The incident file (A.5.26) : impact picture, two clocks, phase stamps, blast radius into the asset, supplier, site, activity, threat, vulnerability, risk and requirement registers.
- The chronology : an append-only, attributed narrative with correction by supersession, which is the account a regulator or a court reads.
- The evidence register (A.5.28) with acquisition metadata, fingerprints, TLP handling caveats, legal hold, retention and an append-only chain-of-custody ledger.
- The regulatory obligation register : one row per (incident, regime, recipient), carrying the legal clock, the decision on whether it applies, the omission judgement, and the filing log.
- The GDPR qualification of an incident and the Art. 33(5) internal register entry.
- The post-incident review (A.5.27) and its outputs into the single nonconformity register, the corrective action plans and the risk register.
Everything above is delivered together, in one release. The PHASE 1 / PHASE 2 markers that appear against individual fields and entities in the entity specifications are an internal build order, not a delivery boundary and not a feature flag. They record the order in which the work is sequenced and merged, and they are kept in the specs because the specs are final and because the order is genuinely useful to an implementer : the flat obligation clock is built and tested before the anchor engine is grafted onto the same table, and the module is deliberately shaped so that no field is ever migrated between the two steps.
Nothing in the module is gated on a later release. There is no shipped configuration in which PersonalDataBreach, NotificationFiling, ReportingAuthority or ReportingObligationTemplate is absent, and no documentation, screenshot, seed row or acceptance criterion in this module describes a half-delivered state.
One genuine prerequisite sits outside the module and lands before it : the compliance.Finding generalisation described in §1.5. It is a separate pull request against the compliance app because it has its own blast radius and its own breaking permission change, not because the incident module is phased.
The module reads widely and is depended on by nothing. Every link below reuses an existing register rather than introducing a parallel one.
| Target module | Nature of the dependency |
|---|---|
| Context |
scopes tenancy on Incident, SecurityEvent, IncidentResponsePlan and PostIncidentReview; context.constants.Criticality reused as severity; context.Site and context.Activity as affected entities; context.Stakeholder as a notification recipient; context.Role as the RACI staffing of the plan; context.Indicator extended with seven predefined incident sources. |
| Assets |
assets.EssentialAsset and assets.SupportAsset as affected entities and as the source of an evidence artefact; assets.Supplier split deliberately into origin_supplier (the third party who caused it) and affected_suppliers (impacted or notified downstream), because NIS2 / DORA third-party reporting and GDPR Art. 28 depend on the causal direction. |
| Risks |
risks.constants.ThreatCategory reused verbatim as the incident taxonomy; risks.Threat as the threat that materialised; risks.Vulnerability as the promotion target of a confirmed weakness and as exploited_vulnerabilities; risks.Risk through realised_risks and through the existing generic source_entity_type / source_entity_id back-pointer; risks.RiskAcceptance forced under review by RG-INC-36. |
| Compliance |
compliance.Requirement as linked_requirements on the incident and the plan, and as failed_controls / controls_to_strengthen on the review; compliance.Finding as the single nonconformity register (§1.5); compliance.ComplianceActionPlan as the corrective work (RG-INC-35); compliance.constants.EffectivenessVerdict imported, never redeclared. |
| Accounts | The custom User model for every people field; accounts.Notification (a GenericForeignKey target, so no new notification model); PERMISSION_REGISTRY and the six SYSTEM_GROUPS; accounts.AccessLog for in-platform reads. |
| Governance (lifecycle) | Six registered lifecycles plus three entities on the core default lifecycle; core.LifecycleEvent as the immutable transition ledger; core.history.build_timeline as the merged history panel. See governance/workflow.md and governance/lifecycle.md. |
| Reports | Phase-3 polish only : the incident register report and the incident input block of the management review. Clause 9.3.2 d)1) and d)2) are reached through the generalised Finding and the predefined indicators, with no new plumbing. |
| Trust Center | No coupling at all. The internal incident register is never foreign-keyed to a published surface. |
The direction is one-way : the incidents module reads the other registers, and no other module gains a foreign key into incidents except compliance.Finding.incident, which is the deliberate bridge described next.
A nonconformity raised by a post-incident review has nowhere clean to live today. It is not a risks.Vulnerability (an organisational weakness such as the joiner-mover-leaver process is not followed has no CVE and no affected asset), and a second nonconformity model would give ISO 27001 clause 10.2 two registers and two answers.
compliance.Finding is therefore generalised from audit finding to nonconformity register entry, in a separate pull request against the compliance app that lands before this module :
-
assessmentbecomesnull=True, blank=True,on_deletemoves fromCASCADEtoSET_NULL; - a
sourceenum is added (audit|incident|management_review|monitoring|complaint), defaulting toauditso every existing row is semantically unchanged; -
effectiveness_reviewed_at,effectiveness_reviewed_byandeffectiveness_verdictare added, closing clause 10.2 d) for audit findings as well as for incident-born ones; -
assessoris currently a requiredPROTECTforeign key toAUTH_USER_MODELwith nonull=True. It becomesnull=True, blank=True, keeps its column, is re-labelled "Raised by" throughverbose_nameonly (no column change, no data migration), and aclean()requires it wheneversource == audit. The PostIncidentReview transition that raises a finding stamps it with the review facilitator; - a
compliance.findingpermission feature is introduced. Findings are gated bycompliance.assessment.*today, which would force an incident responder to hold audit permissions to record a nonconformity. Introducing the feature needs its ownaccountsdata migration, otherwise the codenames exist inPERMISSION_REGISTRY(so tests pass, sinceconftest.pyseeds fromget_all_permissions()) and are granted to nobody on a real database; - the existing MCP tools
list_findings,get_finding,create_finding,update_findinganddelete_findingare re-gated fromcompliance.assessment.*tocompliance.finding.*. This is a breaking contract change for any live MCP integration and is logged as a### Changedentry inCHANGELOG.md, alongside the standalone finding list, detail, viewset and URL routes thatcompliance/urls.pydoes not have today (every finding route is nested under an assessment).
Finding.incident is not added in that pull request : it would create a circular application dependency. It lands with the incidents app, together with PostIncidentReview.raised_findings.
The management-review consequence is stated rather than left to be discovered : section 4a is deliberately source-agnostic, so an incident-born nonconformity reaches the review for free, while the assessment-scoped queries (compliance/models/assessment.py and apply_findings_to_results) stay audit-only by construction, because they iterate the reverse accessor self.findings, which by definition never yields a null-assessment row.
Every rule cited anywhere in the module appears exactly once below. The register runs RG-INC-01 to RG-INC-41 with no gaps and no retired identifiers. Each entity file restates the subset that governs it; this table is the reconciled whole, and it is the one to cite in a commit message or a code comment.
| ID | Rule |
|---|---|
| RG-INC-01 | A SecurityEvent is never an incident. An Incident exists only after an explicit, permissioned A.5.25 assessment transition on the event, or a direct declaration recorded with a detection_source and a named declarer. |
| RG-INC-02 | Exactly one triage decision per event. Reaching confirmed_incident requires a non-null incident FK and reaching confirmed_weakness a non-null vulnerability FK; both are enforced by DB CheckConstraints as well as by the transition gate, so neither raw SQL nor a QuerySet.update() can leave a promoted event pointing at nothing. |
| RG-INC-03 | An event with event_class = weakness can never be promoted to an incident. A weakness that has actually been exploited is a new event of class event linked to the weakness through duplicate_of, so the original reporting history stays intact and the exploitation's reporting delay is measured from its own detection. |
| RG-INC-04 | Discarding an event requires incidents.security_event.approve and a mandatory comment; the comment is written into assessment_notes and into the immutable core.LifecycleEvent, so the register itself is readable without joining the history. |
| RG-INC-05 |
assessment_notes must be non-blank to leave under_assessment by any route, promotion and discard alike. An undocumented assessment is not an assessment. |
| RG-INC-06 | Several events may promote into one incident (Incident.source_events); an event promotes into at most one incident. |
| RG-INC-07 | An incident is deletable only in draft. From detected onward BaseModel.delete() raises LifecycleProtectedError, and PROTECT on IncidentEvidence.incident, IncidentNotification.incident and PostIncidentReview.incident makes deletion impossible in practice. The archive and restore bookends are approve-gated and the restore edge is refused for any incident that has ever left draft, so there is no archive -> restore -> delete path. |
| RG-INC-08 | Every audit gate in this module is enforced in a transition_to() override on the model, never through Transition.form_class, allowed_roles or allowed_users. core/lifecycle.py lifecycle_to_json() omits those three by design, lifecycle_from_json() rebuilds transitions without them, and get_lifecycle() prefers the post_migrate-seeded LifecycleDefinition row over the code default, so a gate declared that way is green in an in-memory unit test and silently dead on every migrated database. All three write surfaces funnel through BaseModel.transition_to() : core/workflow_views.py WorkflowTransitionView.post(), accounts/api/mixins.py _lifecycle_transition() and mcp/tools.py _transition_handler(). A model-level override is the one place that binds web, API and MCP at once. No entity in this module sets lifecycle_transition_url_name. |
| RG-INC-09 | Every lifecycle transition on an incident automatically appends exactly one IncidentTimelineEntry with source = lifecycle, carrying the transition label, the actor and the comment, inside the transition's transaction, so the narrative and the state machine can never diverge. |
| RG-INC-10 | The incident chronology is append-only. IncidentTimelineEntry.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. |
| RG-INC-11 | Reaching triaged requires severity, category and incident_manager, plus a non-blank awareness_justification when awareness_at postdates detected_at. The transition stamps triaged_at and copies severity into the write-once initial_severity, so later severity drift is visible as a difference between two columns rather than only in a history diff. |
| RG-INC-12 | Phase timestamps (declared_at, triaged_at, contained_at, eradicated_at, recovered_at, closed_at, assessed_at, sealed_at, decided_at, sent_at, qualified_at, effectiveness_reviewed_at) are stamped by the transition_to() override only. They are excluded from every ModelForm, are read_only in every serializer, are absent from every MCP writable_fields list, and are cleared only by their matching reopen transition. Prevention at application level, detection via HistoricalRecords. |
| RG-INC-13 |
awareness_at is the single legal clock anchor and is distinct from detected_at. It defaults to detected_at on first save when left blank, must be >= detected_at, and requires a non-blank awareness_justification whenever it postdates detection. Statutory deadlines are never derived from detected_at. |
| RG-INC-14 | Closing an incident is refused unless its PostIncidentReview is in approved or effectiveness_verified, and every IncidentNotification has decision != undecided, and every IncidentEvidence item has left the collected step. Closure additionally requires incidents.incident.approve, a mandatory comment and a confirmation trigger. |
| RG-INC-15 | Reclassification as a mere event is reachable only up to investigating, requires approve plus a mandatory comment, and is refused when any notification already carries a sent_at. Once an incident is contained, it happened; and you cannot un-declare something you have already told a regulator about. |
| 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-17 | An incident with is_exercise = True runs the identical lifecycle with identical gates but is excluded from every KPI, indicator, report, calendar deadline, kanban bucket and dashboard count (an .exclude(is_exercise=True) on the querysets, never a lifecycle state), and never instantiates regulatory notification obligations. Its closure updates IncidentResponsePlan.last_exercise_date, which is the A.5.24 plan-testing evidence and is maintained there and nowhere else. |
| RG-INC-18 |
personal_data_involved = True forces the gdpr_art33_authority obligation to be instantiated at triage regardless of the plan's configured regimes, and creates the PersonalDataBreach record : saved, then transitioned to under_qualification in the same atomic block. Clearing the flag never deletes that record : a breach is ruled out through the not_a_breach transition, never by unchecking a box. |
| RG-INC-19 | When triage produces zero notification obligations, personal_data_involved is False and is_exercise is False, a non-blank Incident.no_obligation_justification is mandatory. A missing regime or template must never read as compliance on a green dashboard. An exercise, which by RG-INC-17 always produces zero obligations, is explicitly exempt : an unqualified gate would force a legal justification for owing nothing on every drill. |
| RG-INC-20 | Evidence acquisition metadata (file, content_hash, hash_algorithm, collected_at, collected_by, collection_method) is immutable once sealed_at is set : save() re-reads the stored row and raises ValidationError naming the field on any attempted change, so the guard covers the form, the serializer, the MCP update tool and the Django admin alike. Prevention at application level, detection via HistoricalRecords. |
| RG-INC-21 | Sealing evidence requires a non-blank content_hash and a non-blank collection_method. There is no path to secured without both, on any surface : an artefact with a perfect hash and no stated method is a file, not evidence. |
| RG-INC-22 | Every IncidentEvidence lifecycle transition that is a handling act appends exactly one EvidenceCustodyEvent with source = lifecycle, inside the transition's transaction; the two Retain edges are the single stated exception and append nothing, because moving an item into its retention period changes how the platform governs it, not who is holding it. Acts that are not state changes (transfer, access, copy, integrity verification, return) are recorded by hand. Custody rows are append-only, ordered by occurred_at, and each occurred_at must be >= the previous row's. transferred / released / returned / destroyed require a named counterparty. There is no cryptographic hash chain : nothing in Cairn hash-chains anything, an HMAC keyed on SECRET_KEY would be invalidated wholesale by a routine key rotation, and a chain would in any case prove something about the database rather than about the artefact in the vault. |
| RG-INC-23 | An integrity verification recording integrity_ok = False sets the parent's last_integrity_check_ok, raises a danger badge on the evidence row, on the incident detail page and on the dashboard widget, and fires EVIDENCE_INTEGRITY_FAILED to the evidence collector, the incident manager and the holders of incidents.evidence.approve in scope. A verification that could not read the artefact is not a verification failure : integrity_ok stays null, last_integrity_check_ok is left unchanged, the failure to read is recorded in notes, and an operational alert goes to whoever can remount a volume. |
| RG-INC-24 | Destroying evidence requires incidents.evidence.approve, a mandatory comment, a confirmation, legal_hold = False and a retention_until strictly in the past. Destruction stamps destruction_authorised_by, deletes the stored artefact while retaining original_filename, file_size, content_hash and hash_algorithm, and appends a final destroyed custody event. The IncidentEvidence row itself is never deleted. |
| RG-INC-25 | An obligation whose decision is not_required must carry a non-blank decision_rationale (DB CheckConstraint), and reaching that step requires incidents.notification.approve, a mandatory comment, a named decided_by and a decided_at. This is the GDPR Art. 33(1) omission judgement, and it is the single most audited sentence in a breach file. |
| RG-INC-26 | Recording an actual filing requires only incidents.notification.update. Only the transitions that declare an obligation extinguished (not_required, and the archive bookend) require approve, so the operator on a 24-hour clock is never blocked waiting for an approver : a late filing is a breach, an early one never is. |
| RG-INC-27 |
due_at is recomputed in save() from the resolved anchor plus deadline_hours only while first_submitted_at is null, and is never editable directly on any surface. Obligations with no_fixed_deadline = True carry a null due_at, are never counted late, and are surfaced in a dedicated no statutory deadline bucket rather than hidden. Never fabricate a deadline for an obligation that legally has none. |
| RG-INC-28 | Overdue is always derived (due_at < now AND sent_at IS NULL AND the step is not terminal), never stored as a status. Lateness is frozen once : first_submitted_at, late_by and NotificationFiling.was_late are stamped at the first filing and never recomputed, so a later correction of awareness_at can never silently un-breach a filed record. |
| RG-INC-29 | A notification's content, channel and sent_at are write-once once sent_at is set. An amendment is an additional NotificationFiling with is_correction = True and, where it replaces a statement, supersedes, on the same obligation, so the same-obligation relationship is never lost and the register never shows two answers to one duty. |
| RG-INC-30 | Obligation terms generated from a ReportingObligationTemplate (regime, recipient_kind, obligation_reference, clock_anchor, deadline_hours, no_fixed_deadline, content_requirements, authority) are snapshotted at creation and are never rewritten by a later template edit; the template foreign key is PROTECT. Generation only considers templates and authorities in a reportable() lifecycle state, never an is_active boolean literal. |
| RG-INC-31 | Exactly one PostIncidentReview per incident (OneToOneField, PROTECT), created automatically on entry to the post_incident_review step : saved, then transitioned to scheduled in the same atomic block. Its scopes are copied from the incident at creation and re-synced by Incident.save() whenever the incident's scopes change, so the review can never drift out of scope alignment with the incident it reviews. |
| RG-INC-32 | Leaving in_progress on a review requires a non-blank root_cause and similar_incidents_checked = True; reaching approved additionally requires an effectiveness_review_date; reaching effectiveness_verified requires incidents.review.approve, a mandatory comment, a non-blank effectiveness_verdict and an effectiveness_reviewed_by. This is the ISO 27001 clause 10.2 d) and f) record. |
| RG-INC-33 | No automated RTO / MTD breach claim is made anywhere in the module. assets.EssentialAsset.max_tolerable_downtime, recovery_time_objective and recovery_point_objective are free-text CharField(max_length=100) values such as 4 hours, so the register reports the measured outage_duration and lists each affected asset's declared objective verbatim, side by side, and declines to conclude. Migrating those three fields to DurationField is recorded as an m2 prerequisite, not smuggled into this module. |
| RG-INC-34 | A nonconformity raised by a post-incident review is a compliance.Finding with source = incident and incident = <incident>, assessment left null : the one ISO 27001 clause 10.2 register. A risk revealed by an incident is a risks.Risk with risk_source = RiskSourceType.INCIDENT, source_entity_type = "incidents.Incident" and source_entity_id = incident.pk, reusing the existing generic back-pointer. No new foreign key on Risk, and no second nonconformity model. |
| RG-INC-35 | Corrective work is recorded exclusively as compliance.ComplianceActionPlan rows linked from PostIncidentReview.corrective_action_plans, reusing that model's eight-step lifecycle, owner and assignees, target date, progress, cost estimate and per-transition audit row. IncidentResponseAction exists only for in-incident operational steps and carries a plain status column, never a lifecycle : during the incident it is a response action, because of the incident it is an action plan. |
| RG-INC-36 | An incident realising a risk that carries an active risks.RiskAcceptance forces that acceptance under review : the daily sweep notifies the acceptance owner and sets review_date on every linked risk still in a reportable step. This is a derived query hung off the existing expire_risk_acceptances sweep, not a stored edge. |
| RG-INC-37 | Every report, KPI, indicator, calendar feed, kanban bucket and link picker filters through reportable() / linkable() / linkable_or_linked() / deletable_states(). No incident, event, evidence, notification, review or breach state literal appears anywhere outside incidents/constants.py, and the prohibition covers the plain ResponseActionStatus enum as much as it covers the lifecycle step codes. |
| RG-INC-38 | Scope tenancy : Incident, SecurityEvent, IncidentResponsePlan and PostIncidentReview carry scopes (ScopedModel). IncidentEvidence, IncidentNotification, IncidentTimelineEntry, IncidentResponseAction and PersonalDataBreach inherit the incident's scope through scope_parent_lookup = "incident__scopes" and are never independently scoped; grandchildren chain it (evidence__incident__scopes, notification__incident__scopes). ReportingAuthority and ReportingObligationTemplate are deliberately instance-wide and set scope_filtered = False explicitly. See §5. |
| RG-INC-39 | Permission features are exactly six and never grow : incident, security_event, evidence, notification, review and response_plan, each with the five standard create / read / update / delete / approve actions only. The catalogue entities gate on response_plan and PersonalDataBreach on notification. No custom verbs, so the six SYSTEM_GROUPS suffix lambdas grant the module unchanged and the group matrix screen, which renders a hardcoded action list, displays every codename. |
| RG-INC-40 | The daily escalate_incident_deadlines management command sweeps unfiled notification obligations whose due_at is inside the alert window or already past, overdue IncidentResponseAction rows, and incidents sitting in detected or triaged past the severity SLA. It takes --dry-run, uses timezone.localdate(), excludes terminal steps and statuses, and iterates with a per-row save() (never QuerySet.update()) so HistoricalRecords captures every change. |
| RG-INC-41 | Confirming a personal data breach requires the full GDPR Art. 33(3)(a)-(d) set (nature, dpo_contact, likely_consequences, measures_taken, all non-blank), a non-null high_risk_to_rights for the Art. 34(1) determination, a non-blank article_34_exemption_justification whenever an Art. 34(3) ground is claimed, plus incidents.notification.approve and a mandatory comment. A confirmed breach with an empty likely consequences is a filing that cannot be drafted, and None is not a verdict. |
The module registers six lifecycles of its own in incidents/lifecycles.py, runs three entities on the core default 4-state lifecycle, and gives four entities no lifecycle at all.
| Lifecycle | Entity | Authoring | Steps |
|---|---|---|---|
security_event |
SecurityEvent | generated from transition constants |
draft, reported, under_assessment, confirmed_incident, confirmed_weakness, discarded, archived
|
incident |
Incident |
hand-authored Step / Transition lists |
draft, detected, triaged, investigating, contained, eradicated, recovered, post_incident_review, closed, reclassified, archived
|
incident_evidence |
IncidentEvidence | hand-authored |
draft, collected, secured, analysed, retained, released, destroyed, archived
|
incident_notification |
IncidentNotification | generated |
draft, assessed, required, drafted, sent, acknowledged, not_required, archived
|
post_incident_review |
PostIncidentReview | generated |
draft, scheduled, in_progress, submitted, approved, effectiveness_verified, cancelled, archived
|
personal_data_breach |
PersonalDataBreach | generated |
draft, under_qualification, confirmed, documented, not_a_breach, archived
|
core default
|
IncidentResponsePlan, ReportingAuthority, ReportingObligationTemplate | core |
draft, pending, validated, archived
|
| none | IncidentTimelineEntry, EvidenceCustodyEvent, NotificationFiling, IncidentResponseAction | n/a | n/a |
Four cross-cutting properties apply to every lifecycle-bearing entity in the module, and each is argued in full in the entity file that owns it.
Registration fails silently if forgotten. core/lifecycle.py lifecycle_name_for() resolves LIFECYCLE_NAME only if name and name in LIFECYCLE_REGISTRY. An incidents/apps.py whose ready() does not import incidents.lifecycles therefore downgrades every model in the module to the core 4-state lifecycle, quietly, in tests as well as in production, with no error anywhere. The module ships a test per entity asserting Model.get_lifecycle().name == "<expected>" so the omission fails loudly.
The archive and restore bookends are hand-declared everywhere. lifecycle_from_state_flags() appends ANY -> archived and archived -> draft with no permission_action and no requires_comment, and user_can_perform() allows any transition whose permission_action is empty. With a deletable = True draft step, that pair is an archive -> restore -> delete path open to anyone who can reach the transition endpoint : on IncidentEvidence it destroys a sealed A.5.28 row, on IncidentNotification it erases the record of a decision not to notify, on PersonalDataBreach it destroys a GDPR qualification. Every lifecycle the module declares therefore lists archived explicitly among its steps so nothing is auto-wired, hand-declares ANY -> archived with permission_action = "approve" and requires_comment = True, hand-declares archived -> draft with permission_action = "approve", hand-declares the draft -> <first domain step> entry edge, and refuses the restore edge in transition_to() for any row whose core.LifecycleEvent history records a step other than draft or archived. The three entities on the core default lifecycle need none of this : its archive edge already carries approve and it declares no restore transition at all. Do not add one.
Nothing is ever created in a domain step. BaseModel.save() calls _ensure_initial_step() and Lifecycle.initial_step returns the single StepKind.DRAFT step, so every insert lands in draft. Assigning workflow_state = "<domain step>" at insert would stick, because the snap only fires on a blank or unknown value, but it would leave no core.LifecycleEvent row, so the object would have no recorded entry into the register, which is exactly the audit trail the module exists to produce. Every auto-creation path therefore does, inside one transaction.atomic() block :
obj = Model(...)
obj.save()
obj.transition_to("<domain step>", user, enforce_permission=False)enforce_permission=False is correct on these paths : the permission was already checked on the parent transition the user actually performed, and the child row is a consequence of it, not a separate act. The three cases are the notification obligations moved to assessed at triage, the PostIncidentReview moved to scheduled when the incident enters the review phase, and the PersonalDataBreach moved to under_qualification. Regression tests assert both that the generators produce the domain step with a matching LifecycleEvent, and that a bare Model.objects.create(...) does not.
Two lifecycles are hand-authored on purpose. CLAUDE.md prescribes generating a lifecycle from its transition constants, and four of the module's six follow that rule. incident and incident_evidence do not, for a reason the generator cannot accommodate : lifecycle_from_state_flags() builds every Step(...) with no triggers= argument and its tuple contract has no slot for one, so a generated lifecycle physically cannot declare the confirmation gate that incident closure and evidence destruction rest on. Both are declared as explicit Step and Transition lists, with the step codes still exported as constants from incidents/constants.py so RG-INC-37 holds. Two honest caveats : no lifecycle in Cairn uses Trigger today, so the opts.confirm branch of templates/includes/lifecycle_stepper.html has never run and this module is its first user, shipping an explicit test of that path in both themes and at mobile width; and triggers do survive the LifecycleDefinition round-trip, so it is only the generator that cannot express them. The confirmation is a UX affordance, not a security control : the server-side gates apply identically to a DRF or MCP caller that never sees a modal.
Module incidents in accounts/constants.py PERMISSION_REGISTRY, with MODULE_LABELS gaining "incidents": _("Incidents"). Codenames follow the platform convention module.feature.action. Six features times five standard actions is thirty codenames, and the number is capped for the life of the module.
| Codename family | Covers | What approve gates |
|---|---|---|
incidents.incident.create / .read / .update / .delete / .approve
|
Incident, and by delegation IncidentTimelineEntry and IncidentResponseAction | Close, reopen after closure, reclassify, archive, restore |
incidents.security_event.create / .read / .update / .delete / .approve
|
SecurityEvent | Discard an event : the A.5.25 this was not an incident verdict |
incidents.evidence.create / .read / .update / .delete / .approve
|
IncidentEvidence, and by delegation EvidenceCustodyEvent | Release to a counterparty, destroy, archive, restore |
incidents.notification.create / .read / .update / .delete / .approve
|
IncidentNotification, PersonalDataBreach, NotificationFiling | Decide not to notify, confirm or rule out a breach, complete or reopen the Art. 33(5) record. Recording an actual filing needs only update (RG-INC-26) |
incidents.review.create / .read / .update / .delete / .approve
|
PostIncidentReview | Approve the review and record the effectiveness verdict |
incidents.response_plan.create / .read / .update / .delete / .approve
|
IncidentResponsePlan, ReportingAuthority, ReportingObligationTemplate | Put a plan, an authority or a legal template into force, and archive it |
Promotion of an event to an incident additionally requires incidents.incident.create.
SYSTEM_GROUPS in accounts/constants.py assigns permissions to the six system roles through suffix filters on the codename, not through per-module enumeration. Because the module uses only the standard PermissionAction verbs and invents no custom one, every group picks up all thirty codenames with no change to the registry :
| Role | Filter | Resulting incidents permissions |
|---|---|---|
| Super Administrateur | everything | All thirty. |
| Administrateur | everything except system.admin_django.access
|
All thirty. |
| RSSI / DPO | suffixes .read, .create, .update, .approve (plus .access, .validate, .close, .cancel) |
read + create + update + approve on all six features, no delete. This is the correct set : the DPO pronounces the breach verdict, the CISO puts the plan into force, and neither hard-deletes an audit record. |
| Contributeur | suffixes .read, .create, .update, .implement, not system.*
|
read + create + update. Responders declare, triage, investigate, collect evidence, draft filings and record them, but never close an incident, never discard an event, never destroy evidence and never rule out an obligation. |
| Auditeur | suffix .read
|
Read on all six features, which is exactly the set an auditor needs and no more. |
| Lecteur | suffix .read, not system.*
|
Read on all six features. |
The approve / non-approve split is the module's governance in one line : the person who works the incident is not the person who declares it over, declares an obligation extinguished, or destroys the evidence.
The group matrix screen in accounts/views.py renders a hardcoded action list, so a custom verb would exist in the database and be invisible in the UI that administers it. That is the second reason the cap is a rule and not a preference.
BaseModel derives the permission namespace from app_label.model_name. That is correct for exactly one entity in this module. Every other lifecycle-bearing model must override it, because the derived namespace would match no feature in PERMISSION_REGISTRY and every lifecycle permission check would then silently evaluate against a codename nobody holds.
| Model | Derived (wrong) | workflow_perm_namespace |
|---|---|---|
Incident |
incidents.incident |
not overridden : already correct |
SecurityEvent |
incidents.securityevent |
incidents.security_event |
IncidentEvidence |
incidents.incidentevidence |
incidents.evidence |
IncidentNotification |
incidents.incidentnotification |
incidents.notification |
PostIncidentReview |
incidents.postincidentreview |
incidents.review |
IncidentResponsePlan |
incidents.incidentresponseplan |
incidents.response_plan |
ReportingAuthority |
incidents.reportingauthority |
incidents.response_plan |
ReportingObligationTemplate |
incidents.reportingobligationtemplate |
incidents.response_plan |
PersonalDataBreach |
incidents.personaldatabreach |
incidents.notification |
accounts/migrations/0056_add_incidents_permissions.py creates the thirty Permission rows and attaches them to the six system groups. It copies accounts/migrations/0053_add_certificate_permissions.py verbatim in shape : self-contained constants, an _ends() helper reproducing the group lambdas, get_or_create(is_system=True), a Group.DoesNotExist guard, and a reverse operation that deletes the codenames. It depends on ("accounts", "0055_alter_accesslog_event_type").
The PERMISSION_REGISTRY entry alone makes the test suite pass, because conftest.py seeds groups from get_all_permissions(). The migration must therefore land in the same commit, or a production database will have a green test suite and no grants.
The compliance.finding feature introduced by the prerequisite (§1.5) needs its own accounts data migration in its own pull request, for exactly the same reason.
ScopeFilterMixin (accounts/mixins.py) and ScopeFilterAPIMixin (accounts/api/mixins.py) already honour a model-level scope_parent_lookup, so list views and viewsets filter a non-ScopedModel child correctly by declaring one. Three other call sites do not, and they leak every non-ScopedModel child across scopes today, for the existing modules as well as for this one. All three guard scope with hasattr(obj, "scopes"), which is false for exactly the models that need the parent lookup.
This is a core change in the module's pull request, not an incidents-app detail : two of the three endpoints are generic and shared by every module. It is logged under a ### Security entry in CHANGELOG.md.
| Call site | Current behaviour | Required change | What is reachable without it |
|---|---|---|---|
mcp/tools.py _filter_by_scopes()
|
Handles context.Scope, then a direct scopes M2M, then return qs unfiltered. There is no scope_parent_lookup equivalent at all. |
Extend the signature to accept a parent_lookup, and thread a scope_parent_lookup argument through _register_crud(), _list_handler(), _get_handler(), _transition_handler() and _allowed_transitions_handler(). |
list_incident_evidence, list_evidence_custody_events, list_incident_notifications, list_overdue_incident_notifications, list_incident_timeline_entries, list_incident_response_actions, list_personal_data_breaches and list_notification_filings return every row on the instance to any holder of the corresponding .read. That includes evidence hashes, storage locations, TLP:RED handling caveats, custody counterparties named at other organisations, verbatim regulatory filing content, omission rationales, and breach data-subject counts. |
core/workflow_views.py WorkflowTransitionView
|
Guards with if allowed_scopes is not None and hasattr(obj, "scopes")
|
Honour a model-level scope_parent_lookup attribute in the same guard. |
The evidence release and destroy transitions, the notification not_required decision (the GDPR Art. 33(1) omission), and the breach confirm / not_a_breach verdicts are performable cross-scope. A user scoped to one subsidiary can destroy another subsidiary's sealed evidence with a valid approve permission and a perfectly clean audit trail showing they were entitled to. |
core/history_views.py HistoryPartialView
|
The same hasattr(obj, "scopes") guard |
The same change. | The full field-level history of an out-of-scope evidence row, obligation, breach qualification, custody row, timeline entry, response action or filing is readable, including every value the acquisition metadata or the omission rationale ever held. |
Because the two web endpoints are generic, the guard is fixed once and every non-ScopedModel child in the platform benefits. The module ships tests asserting that a user scoped out of an incident receives a 404 from workflow:transition and from the history partial for its evidence and its obligations, and an empty list from list_incident_evidence, list_evidence_custody_events and list_personal_data_breaches.
The catalogue entities are the deliberate exception : ReportingAuthority and ReportingObligationTemplate carry neither scopes nor scope_parent_lookup, because the CNIL is the CNIL for every scope of the ISMS. Their viewsets and MCP registrations set scope_filtered = False explicitly rather than inheriting it by omission, so the choice reads as a decision in review instead of looking like the same oversight this fix repairs.
Base path /api/v1/incidents/, mounted in core/urls.py. incidents/api/urls.py declares app_name = "incidents-api" and a DefaultRouter.
incidents, security-events, response-plans, evidence, custody-events, notifications, response-actions, timeline-entries, post-incident-reviews, reporting-authorities, obligation-templates, personal-data-breaches, notification-filings.
Permissions follow the newest module precedent, trust_center/api/views.py _ManagedViewSet, and not the older habit of importing another app's ModulePermission subclass. ContextPermission is the context app's subclass and its only content is an extra action map; importing it into a new module borrows another domain's vocabulary for no benefit.
class _IncidentViewSet:
permission_classes = [IsAuthenticated, ModulePermission]
permission_module = "incidents"
custom_action_map = {"transition": "update"}Every viewset in the module extends this base and sets an explicit permission_feature. Two viewsets add their own actions to the map rather than redefining it, because the shared base must stay identical across all thirteen :
-
IncidentEvidenceViewSetextends it with{"verify-integrity": "update", "download": "read"}; -
IncidentNotificationViewSetandNotificationFilingViewSetextend it with{"proof": "read"}.
BatchCreateMixin, ScopeFilterAPIMixin, LifecycleAPIMixin, HistoryAPIMixin, CreatedByMixin, viewsets.ModelViewSet.
-
LifecycleAPIMixinis dropped on the four entities that run no lifecycle (IncidentTimelineEntry, EvidenceCustodyEvent, IncidentResponseAction, NotificationFiling), so notransition/route is generated for them. -
ScopeFilterAPIMixindeclaresscope_parent_lookupon every child :incident__scopes,evidence__incident__scopes,notification__incident__scopes. The catalogue viewsets setscope_filtered = Falseexplicitly.
| Route | Notes |
|---|---|
GET/POST /<resource>/ |
List with per-entity filters (incidents/api/filters.py), search (?search=), ordering (?ordering=) and lifecycle filtering (?workflow_state=a,b). |
POST /<resource>/batch/ |
BatchCreateMixin : max 100 items, non-atomic, per-item {index, status, id, reference}. |
GET/PUT/PATCH/DELETE /<resource>/<uuid>/ |
DELETE succeeds only in a deletable_states() step; otherwise BaseModel.delete() raises and the endpoint returns 409. |
GET/POST /<resource>/<uuid>/transition/ |
LifecycleAPIMixin, routed through transition_to(enforce_permission=True), so every gate in §2 applies identically to an API caller. A forbidden transition returns 403, an invalid one 400. |
GET /<resource>/<uuid>/history/ |
core.history.build_timeline, merging LifecycleEvent and HistoricalRecords. |
GET /evidence/<uuid>/download/ |
The only way to retrieve an artefact. Resolves the row, checks incidents.evidence.read and the caller's scopes through incident__scopes, then streams with Content-Disposition: attachment. 404 for a registered-by-reference row and for a destroyed one. |
POST /evidence/<uuid>/verify-integrity/ |
Runs the verification and returns the three-way outcome explicitly, so a caller can tell a tamper from a missing volume. |
GET /notifications/<uuid>/proof/ and GET /notification-filings/<uuid>/proof/
|
Permission-checked and scope-checked proof bytes. proof_file_content never appears in a list or detail payload. |
Append-only entities restrict http_method_names at the routing layer, not only in the serializer : timeline-entries and custody-events are create, list and retrieve only, and notification-filings adds exactly one PATCH route that accepts only the three completion fields (outcome, acknowledged_at, external_reference) and rejects every other key with a 400 rather than ignoring it.
Two serializers per entity : <Entity>Serializer (full, with read_only_fields covering id, reference, created_by, created_at, updated_at, version and every transition-stamped timestamp) and <Entity>ListSerializer for the index, switched in get_serializer_class() on self.action == "list". Lifecycle entities expose status = CharField(source="workflow_state", read_only=True). Foreign-key display names are exposed as *_name read-only fields backed by a model @property. File payloads and proof_file_content never appear in any serializer.
_register_incidents_tools(server) is added to register_all_tools() and resolves every model through _get_model("incidents", ...) rather than a direct import.
_register_crud() generates the nine standard tools (list_*, get_*, create_*, batch_create_*, update_*, delete_*, transition_*, *_allowed_transitions, get_*_history) for :
| Registration | Permission namespace | Scope argument |
|---|---|---|
incident |
incidents.incident |
scopes M2M |
security_event |
incidents.security_event |
scopes M2M |
incident_response_plan |
incidents.response_plan |
scopes M2M |
post_incident_review |
incidents.review |
scopes M2M |
incident_evidence |
incidents.evidence |
scope_parent_lookup="incident__scopes" |
incident_notification |
incidents.notification |
scope_parent_lookup="incident__scopes" |
personal_data_breach |
incidents.notification |
scope_parent_lookup="incident__scopes" |
incident_response_action |
incidents.incident, has_approve=False
|
scope_parent_lookup="incident__scopes" |
reporting_authority |
incidents.response_plan |
scope_filtered=False |
obligation_template |
incidents.response_plan |
scope_filtered=False |
incident_response_action generates no transition_* and no *_allowed_transitions tool : the child row runs no lifecycle.
| Tool | Permission | Purpose |
|---|---|---|
declare_incident_from_event |
incidents.security_event.update and incidents.incident.create
|
Runs the whole A.5.25 promotion as one atomic act, so an agent cannot leave a half-promoted event behind. |
create_incident_timeline_entry / list_incident_timeline_entries
|
incidents.incident.create / .read
|
Create-and-read only. No update tool, no delete tool : an agent must never rewrite an incident narrative. |
create_evidence_custody_event / list_evidence_custody_events
|
incidents.evidence.update / .read
|
Same append-only shape. source is forced to manual on every MCP-created row. |
verify_evidence_integrity |
incidents.evidence.update |
Appends an integrity_verified custody act with the measured digest and returns the three-way verdict, rather than letting an agent assert one. |
list_overdue_incident_notifications |
incidents.notification.read |
The are we late question answered in one call : obligation, incident, regime, recipient, due_at, hours overdue, owner. The single highest-value read tool in the module. |
record_notification_filing |
incidents.notification.update |
Creates the NotificationFiling and freezes first_submitted_at, late_by and was_late atomically, so a transmission can never be recorded without the lateness verdict that goes with it. |
record_filing_outcome |
incidents.notification.update |
Sets outcome, acknowledged_at and external_reference once, and refuses everything else. |
Every enum field carries an explicit enum list in field_overrides; every HTML field uses _html_field(); every foreign-key id argument carries a description naming its lookup tool (Use list_suppliers to get valid IDs). Transition-stamped timestamps never appear in writable_fields : a decision is a transition, never a field write. The file payload of an evidence item and the proof_file_content bytes are neither readable nor writable through MCP.
mcp/tools.py HELP_TEXT gains the reference-prefix entries Incident=INCD, SecurityEvent=EVNT, IncidentResponsePlan=IRPL, IncidentEvidence=EVID, IncidentNotification=INOT, PostIncidentReview=PIRV, IncidentResponseAction=IRAC, ReportingAuthority=RGAU, ReportingObligationTemplate=ROBT, PersonalDataBreach=PDBR, NotificationFiling=NFIL, and a new TOPIC_INCIDENTS constant joins ALL_TOPICS with a per-entity Writable fields / enum values / Filters / Ref prefix section, plus the topic name in the help tool's description and its topic property.
Do not copy a neighbouring
HELP_TEXTline when adding these. The existing block mis-states two prefixes : it saysIndicator=INDIwhere the model declaresINDC, andActionPlan=ACTPLwhere the model declaresCAPL. Read each prefix off the model'sREFERENCE_PREFIX, and note that the correctINDCis one letter-order away from this module'sINCD.
assistant/catalog.py gains read-only ToolSpec entries for list_incidents, get_incident, list_incident_notifications and list_security_events, with allowed_args, title_fields, summary_fields = ("incident_manager_name", "severity", "status") and detail_route = "incidents:incident-detail". Without them Ask Cairn cannot reach the register at all.
New NotificationType values plus matching notify_* helpers in accounts/notifications.py, following notify_lifecycle_submitted : render per recipient under translation.override(recipient.language), create the rows in the same transaction, then transaction.on_commit(_deliver) so a rolled-back transition sends nothing. No new notification model : accounts.Notification already targets any object through a GenericForeignKey, and naming the route incidents:incident-detail makes _target_url resolve with no special casing. A matching accounts migration alters the notification_type choices.
| Event | Recipients | Channel |
|---|---|---|
INCIDENT_DECLARED : the draft -> detected transition |
Holders of incidents.incident.read in the incident's scopes, and the response plan owner |
In-app, email |
INCIDENT_ASSIGNED : incident_manager set or changed |
The new incident manager | In-app, email |
INCIDENT_SEVERITY_RAISED : severity moves above initial_severity after triage |
The incident manager, the response plan owner, holders of incidents.incident.approve in scope |
In-app, email |
NOTIFICATION_DEADLINE_APPROACHING : daily sweep, decision = required, sent_at null, due_at inside the alert window |
The incident manager, holders of incidents.notification.approve in scope |
In-app, email |
NOTIFICATION_OVERDUE : same sweep, due_at already past |
As above, plus the response plan owner | In-app, email |
EVIDENCE_INTEGRITY_FAILED : a custody event records integrity_ok = False
|
The evidence collector, the incident manager, holders of incidents.evidence.approve in scope |
In-app, email |
POST_INCIDENT_REVIEW_DUE : scheduled_date or effectiveness_review_date falls due |
The facilitator and the participants | In-app |
RISK_REVIEW_TRIGGERED_BY_INCIDENT : a review sets risk_reassessment_required, or an incident realises a risk carrying an active RiskAcceptance (RG-INC-36) |
The risk owner and the acceptance owner | In-app, email |
INCIDENT_SEVERITY_RAISED is not only a message : it is the second trigger point that re-runs obligation generation, because a severity raise can cross a template's min_severity floor and start a 24-hour NIS2 clock that did not exist an hour earlier. Its absence is a missed statutory deadline, which is why it is an event and not a nightly job.
Exercises (is_exercise = True) are excluded from every deadline feed and therefore from the two deadline notifications.
Every detail page in this module uses a strict 2-column card layout with no nav-tabs : stacked cards and collapsible Bootstrap sections in the left column, a sticky metadata sidebar on the right, per the platform's detail-page doctrine. Every state change is driven by {% include "includes/lifecycle_stepper.html" %} fed by LifecycleStepperMixin, and by nothing else : never a status select, never plain buttons. State badges use {% workflow_badge obj %}.
List pages use the full house stack : LoginRequiredMixin, PermissionRequiredMixin, ListSummaryMixin, PredefinedFilterMixin, AdvancedFilterMixin, SavedFilterMixin, ColumnPreferenceMixin, ScopeFilterMixin, SortableListMixin, ListView, with ListSummaryMixin strictly to the left of ScopeFilterMixin, and per-entity *_FILTER_GROUPS / *_TEXT_FILTERS / *_COLUMNS constants declared above the view. Create, update and delete use HtmxFormMixin drawer modals.
| Surface | Left column | Sticky sidebar |
|---|---|---|
Incident detail /incidents/<uuid>/
|
Summary and impact (CIA flags, personal data, TLP, category, outage_duration shown beside each affected asset's declared RTO / MTD verbatim with no breach claim); Chronology (append-only, ordered by occurred_at, inline add form over HTMX, lifecycle-sourced entries visually distinguished); Response actions; Evidence; Regulatory notifications; Personal data qualification; Post-incident review summary; Linked registers as collapsible sections |
State badge, severity with initial_severity alongside when they differ, incident manager and reporter, response plan link, the eight clock stamps, scopes, tags, history trigger |
| Security event detail | Observation (with the reporting delay computed and shown); Assessment; Promotion targets | State badge, triage decision, reporter avatar replaced by an "Anonymous report" badge when is_anonymous, never left blank |
| Evidence detail | Acquisition; Integrity; Chain of custody with an inline Record custody act form | TLP chip, legal hold, retention_until, sealed and collected stamps, parent incident |
| Notification detail | Obligation with the content_requirements checklist rendered beside the drafting field; Decision (a prominent full-width card when decision = not_required); Filing with the filing history table |
The deadline with a live countdown and the anchor it derives from stated in words, the no statutory deadline or deadline pending badge |
| Post-incident review detail | Root cause; What went well and what failed; Outcomes; Effectiveness | Facilitator, participants, held_at
|
| Response plan detail | Procedure, classification scale, escalation matrix, reporting channels, evidence procedure, lessons-learned procedure, as collapsible sections | Owner, approver, effective_from, review_date, last_exercise_date, the incidents handled under this plan |
Catalogue (reporting-authorities, obligation-templates) |
Identity and filing channel; the clock rendered as one plain-language sentence above the raw fields | Templates and generated obligations, with counts linking to filtered lists |
-
Dashboard : an
open_incidentswidget registered inDASHBOARD_WIDGETSwith its partial undertemplates/dashboard/widgets/, showing open incidents by severity, overdue notification obligations and any evidence item whose last integrity check failed. Widget partials read context variables, soGeneralDashboardView.get_context_datamust set them (see §12). -
Kanban : an
incidententity added toENTITY_PERMS,TYPE_ICONSandTYPE_LABELS, an_INCIDENT_BUCKETSstep-to-(column, tone) map, a_build_incidentsbuilder and its_BUILDERSregistration. -
Calendar : an
incidentcategory inALL_CATEGORIESwith entries for notificationdue_at(as a deadline), reviewscheduled_dateandeffectiveness_review_date, evidenceretention_untiland planreview_date, plus the label inbuild_upcoming_deadlines. Exercises are excluded from every feed. -
Global search : a
NAVIGATION_ENTRIESrow, anACTION_ENTRIESquick-create gated onincidents.incident.create, and category entries forIncidentandSecurityEvent(both scope-filtered automatically, since both carryscopes). -
Accent and navigation :
MODULE_ACCENTSgains"incidents"(matching the app label; the map storestrust-centerhyphenated, which is the exception, not the rule), with--module-accent-incidentsand--module-accent-incidents-softdefined in both the light and dark token blocks ofbase.html. An unregistered accent is silently dropped. The sidebar section sits between Risk management and Compliance.
- The
incidentlifecycle has threeStepKind.ARCHIVEDsteps andsecurity_eventhas four, so the dagre renderer draws three and four detached exits : busier than any existing Cairn lifecycle. Both are checked at desktop and mobile widths in both themes before merge. - The confirmation modal (
opts.confirmin the stepper template) has never run in this codebase. Incident closure and evidence destruction are its first users, and both are exercised in both themes on the same pass. - Semantic colour is reserved for status, per the brand guidelines : deadline countdowns, status pills and integrity badges use the status palette, and the navy identity colour is never repurposed for them. Trigger-condition badges on a template, which are not statuses, use navy.
- Integrity is rendered as three visually distinct states and never two : a success tick for a match, a danger badge for a mismatch, and a warning not verifiable badge for an artefact that could not be read.
- Bootstrap Icons exclusively. The multi-select widgets (assets, sites, suppliers, risks, requirements, categories) and the sticky action bars get explicit mobile-first attention, and the six rich-text fields of the response plan form render as an accordion at small widths.
Dedicated incidents Django app. Models in incidents/models/, lifecycles in incidents/lifecycles.py (registered from IncidentsConfig.ready()), state codes and enums in incidents/constants.py, web views in incidents/views.py, forms in incidents/forms.py, REST layer in incidents/api/ (serializers.py, filters.py, views.py, urls.py), the daily sweep in incidents/management/commands/escalate_incident_deadlines.py, and incidents/admin.py registering every model with SimpleHistoryAdmin.
Mounted in the root URL configuration at /incidents/ (incidents.urls, app_name = "incidents") and /api/v1/incidents/ (incidents.api.urls, app_name = "incidents-api").
| Variable | Default | Purpose |
|---|---|---|
INCIDENT_EVIDENCE_MAX_UPLOAD_BYTES |
52428800 (50 MB) |
Caps the inline copy of an evidence artefact (IncidentEvidence file). Enforced in the form, in the serializer and in the MCP layer, not only in the reverse proxy, so every surface refuses the same thing. Above the cap an artefact is registered by reference. |
INCIDENT_NOTIFICATION_MAX_PROOF_BYTES |
10485760 (10 MB) |
Caps the proof-of-filing bytes on IncidentNotification and NotificationFiling. Deliberately an order of magnitude below the evidence cap, because these bytes live in a database column rather than on a volume : a portal receipt is a few hundred kilobytes. |
Both are read from the environment in core/settings.py.
The production media-volume requirement. IncidentEvidence.file is the module's only FileField, and core/urls.py serves MEDIA_URL only under DEBUG. Three consequences are operational requirements of this module, not caveats :
-
A persistent media volume is required.
MEDIA_ROOTdefaults toBASE_DIR / "media", which inside a container is ephemeral. A Docker or Kubernetes install must mount a persistent volume there, and it must be backed up in the same operation as the database. -
The volume must not be served directly. Because Django does not serve it in production, nothing else should : the reverse proxy must not be given a
location /media/alias for this path. A raw media URL would be an unauthenticated, unscoped, guessable-by-UUID download of forensic material. Downloads go through the permission-checked and scope-checked detail action described in §6.4. - Storage-level protection is the operator's job. Cairn guarantees the row, the hash and the ledger. It does not guarantee bytes on a volume an administrator can remove. Encryption at rest, restrictive filesystem permissions and volume snapshots are named in the deployment notes as the operator's side of A.5.28.
A restored database paired with a lost volume makes every inline artefact unreadable at once. That is precisely why verify_evidence_integrity reports file missing as a distinct third outcome and never as a hash mismatch (RG-INC-23) : a single infrastructure mistake must not write a permanent chain-of-custody break into the append-only ledger of every evidence item in the platform on a day when nothing was tampered with.
Every user-facing string is wrapped with _() / pgettext_lazy() in Python or {% trans %} in templates, with a French entry in locale/fr/LC_MESSAGES/django.po. A duplicate (msgctxt, msgid) pair makes manage.py compilemessages fail, and .github/workflows/tests.yml runs compilemessages before pytest, so a collision breaks CI outright rather than breaking a page.
Colliding labels use pgettext_lazy("incident", ...) in Python and {% trans "..." context "incident" %} in templates, with a matching msgctxt "incident" block in the .po. The confirmed collisions across the module are : Closed, Draft, Archived, Approved, Cancelled, Required, Confirmed, Retained, In progress, Planned, Done, Other, Close, Incident, Evidence, Severity, Weakness, Audit, Monitoring, Complaint, Observation, Action, Decision, System, Manual, Import and Email. Where a label already exists with the right French for this context (Jurisdiction, Country, Order, Phone, Rejected, Superseded, Collection method, Location, Notes, Source, Actor, Recorded at), the same msgid is reused and no entry is added : gettext merges the occurrences into one entry with several #: references, which is not a duplicate.
Step and transition labels are the trap, and they must never carry a msgctxt. lifecycle_to_json() stringifies each label with str(...), and lifecycle_from_json() re-wraps the stored string with bare gettext_lazy. A step label carrying a context in code therefore loses that context after the post_migrate round-trip through LifecycleDefinition and resolves to whatever the bare msgid maps to. The fix for a colliding step label is a different English label, never a context :
| Intended label | Existing bare entry | Decision |
|---|---|---|
closed step "Closed" |
"Clôturée" (feminine) | Renamed "Incident closed" : a context would be stripped and the step would render with the wrong gender for incident. |
| Close transition "Close" | "Fermer" (as in closing a dialog) | Renamed "Close the incident". |
retained step "Retained" |
"Retenu" (as in a retained risk, i.e. selected) | Renamed "Retained in custody" : the bare French means the opposite of a custody state. |
draft -> collected transition "Register" |
"Registre" (the noun) | Renamed "Register the item" : a transition label must be a verb. |
assessed step |
"Assessed" would read as a verdict | Labelled "To decide", which is what the step means. |
confirmed step on a breach |
"Confirmé" is taken | Labelled "Confirmed breach", clearing the collision by construction. |
confirmed_weakness step |
"Weakness" is taken | Labelled "Confirmed weakness", same reason. |
| "Draft" / "Archived" / "Archive" / "Restore" | correct French already present | Reused from the core bookend labels. No new entry. |
After editing the .po, verify there is no duplicate msgid without a distinguishing msgctxt.
An incident carries three record sets describing overlapping facts. They are not redundant : they answer different questions, and reconciling them at audit time is real work the module does not pretend away.
| 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. Generic, so it covers every lifecycle-bearing entity in the module. |
BaseModel.transition_to() only |
The process. Which state something was in, from when to when, who moved it and on what stated grounds. The record to cite for a permission or approval question. |
HistoricalRecords |
A full row snapshot per save(), with the acting user, on every entity in the module. |
Every save(), including saves that write no narrative |
The data. What a field held at a given instant, and whether it changed outside the documented flow. 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, correctable only by supersession. |
Responders, plus the transition override (source = lifecycle) and background jobs (source = system) |
The facts. What happened in the world, in real-world order, in words. The account a regulator or a court reads, and the source of the GDPR Art. 33(3)(a) description. 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 is answered by LifecycleEvent; a question about a field value is answered by HistoricalRecords; a question about the world is answered by the chronology. A source = lifecycle entry with no matching LifecycleEvent, or a LifecycleEvent with no matching entry, is a defect and is reported as one : the incident register export carries 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 merges the first two into the generic history panel. The chronology is rendered separately and deliberately : mixing a narrative written for humans into a diff feed written for machines makes both unreadable.
The module uses two, split by artefact size, and the split is stated here so nobody adds a third :
| Pattern | Used by | Why |
|---|---|---|
FileField on a media volume |
IncidentEvidence.file |
A multi-gigabyte disk image has no business in a database column. Follows compliance.AssessmentResultAttachment. |
BinaryField in the database |
IncidentNotification.proof_file_content, NotificationFiling.proof_file_content
|
A portal receipt is a few hundred kilobytes and must survive a restore alongside the row it proves. Follows assets.Contract, assets.Certificate and trust_center.TrustCenterDocument, all of which exclude the column from HistoricalRecords. |
ReferenceGeneratorMixin._generate_next_reference() scans every existing reference sharing a prefix on each insert. That is acceptable for entities counted in tens or hundreds per incident, and it is why IncidentTimelineEntry and EvidenceCustodyEvent deliberately carry no reference prefix : a live incident produces hundreds of narrative rows in an afternoon, and each would cost a full-table scan. NotificationFiling does carry one, because a filing is cited in correspondence and a busy obligation has two or three.
IncidentNotification carries Index(fields=["due_at", "workflow_state"]), which is the index the are we late query runs on from the list page, the calendar, the dashboard widget, the escalation command and MCP. Incident indexes (workflow_state, severity), (severity, detected_at) and (awareness_at,).
Each row states an obligation and the field or entity that satisfies it. Where nothing satisfies it, the row says so.
| Obligation | Satisfied by |
|---|---|
| A.5.24 planning and preparation |
IncidentResponsePlan : procedure, classification_scale, escalation_matrix, reporting_channels, evidence_procedure, lessons_learned_procedure, responsible_roles, approved_by / approved_at / effective_from. Incident.response_plan (PROTECT) records which version each incident was handled under (clause 7.5.3). Plan testing is Incident.is_exercise run through the identical lifecycle, stamping last_exercise_date. |
| A.5.25 assessment and decision | The security_event lifecycle : under_assessment with mandatory assessment_notes, stamped assessed_by / assessed_at, three mutually exclusive permissioned outcomes and an approve-gated, comment-bearing discarded. |
| A.5.26 response | The eleven-step incident lifecycle with write-once phase stamps, IncidentResponseAction typed by containment / eradication / recovery with a required outcome on completion, and the append-only IncidentTimelineEntry chronology. |
| A.5.27 learning from incidents |
PostIncidentReview : root_cause_method, root_cause, contributing_factors, detection_gap, failed_controls, controls_to_strengthen, identified_risks, identified_vulnerabilities, response_plan_update_required feeding back into A.5.24. Closure is blocked until the review is approved (RG-INC-14). |
| A.5.28 collection of evidence |
IncidentEvidence : evidence_type, collection_method, content_hash + hash_algorithm, sealed_at, tlp, legal_hold, retention_until, admissibility_notes; and the append-only EvidenceCustodyEvent ledger with counterparty, location, hash_at_event and integrity_ok. |
| A.6.8 reporting of events and weaknesses |
SecurityEvent : event_class, detection_source, detected_at versus reported_at as the measurable reporting delay, and reporter / reporter_label / is_anonymous with a CheckConstraint guaranteeing the anonymous channel. |
| A.5.5 contact with authorities | ReportingAuthority : the documented catalogue with portal, mailbox, phone, language and filing procedure. |
| A.8.8 technical vulnerability management | A confirmed weakness promotes into the existing risks.Vulnerability register (SecurityEvent.vulnerability); PostIncidentReview.identified_vulnerabilities feeds it after the fact. No parallel weakness register. |
| A.8.16 monitoring activities |
PostIncidentReview.detection_gap plus the mean_time_to_detect predefined indicator, making monitoring effectiveness measurable rather than asserted. |
| clause 10.1 continual improvement | The review outputs : corrective action plans, controls to strengthen, plan updates and the predefined indicator series that shows whether the trend improves. |
| clause 10.2 a) correction |
IncidentResponseAction and Incident.contained_at / eradicated_at / recovered_at. |
| clause 10.2 b) determine the causes, and whether similar nonconformities exist |
PostIncidentReview.root_cause_method + root_cause + contributing_factors, mandatory to leave in_progress; recurrence_likelihood + similar_incidents_checked, which must be True to submit. |
| clause 10.2 c) and e) implement action, make changes to the ISMS |
PostIncidentReview.corrective_action_plans (compliance.ComplianceActionPlan, eight-step lifecycle), isms_changes (M2M to the existing reports.ISMSChange, so show me the ISMS change this incident forced is answered with a record rather than a checkbox), risk_reassessment_required and training_required. |
| clause 10.2 d) review the effectiveness of corrective action |
PostIncidentReview.effectiveness_review_date / effectiveness_reviewed_at / effectiveness_reviewed_by / effectiveness_verdict (RG-INC-32), mirrored onto compliance.Finding by the prerequisite so the same record exists for audit findings. |
| clause 10.2 f) retained documented information | The whole record, plus HistoricalRecords on every model and the immutable core.LifecycleEvent per transition. |
| clause 9.3.2 d)1) and d)2) management review inputs | Incidents reach the review as nonconformities and corrective actions through the generalised compliance.Finding (section 4a) and as monitoring and measurement results through the predefined incident indicators (section 4b). Clause 9.3.2 does not name incidents as a separate input, and 9.3.2 c) is changes in the needs and expectations of interested parties : citing it for incidents would be wrong, and this module does not. |
| Obligation | Satisfied by |
|---|---|
| Art. 33(1) notify the supervisory authority within 72 hours of becoming aware, unless unlikely to result in a risk |
IncidentNotification with regime = gdpr_art33_authority, anchored on Incident.awareness_at (never detected_at), deadline_hours = 72, stored due_at. The unless is the not_required terminal step : an approve-gated, comment-bearing transition with a named decided_by, a stamped decided_at and a mandatory decision_rationale (RG-INC-25). |
| Art. 33(2) a processor notifies the controller without undue delay |
regime = gdpr_art33_2_controller, no_fixed_deadline = True, recipient_supplier as the controller. Generated only when PersonalDataBreach.controller_role = processor, and never alongside Art. 33(1). |
| Art. 33(3)(a)-(d) minimum content |
PersonalDataBreach : nature + data_categories + data_subject_categories + approximate_data_subjects + approximate_records (a), dpo_contact (b), likely_consequences (c), measures_taken (d), all enforced as preconditions of the confirm transition (RG-INC-41), and rendered in article order on the page because that is the order the filing form asks for. |
| Art. 33(4) information may be provided in phases | Successive NotificationFiling rows with is_correction = True and, where a statement is replaced, supersedes, on the same obligation. Never an edit of the original filing. |
| Art. 33(5) internal documentation of every breach, notified or not | The PersonalDataBreach record itself : its documented lifecycle step is the register entry, and register_entry_reference keeps an externally held register reconcilable. The not_a_breach step is the entry an inspector asks for when personal data was involved and no filing was made. |
| Art. 34(1) communicate to data subjects on high risk |
regime = gdpr_art34_data_subject, no_fixed_deadline = True, generated only when high_risk_to_rights is exactly True. None is not a match. |
| Art. 34(3)(a)-(c) exemptions |
Art34Ground (encryption, subsequent_measures, disproportionate_effort), each requiring a written justification. The obligation is still generated and is closed through its own not_required decision with that justification as the rationale : an exemption that silently suppresses a row is an absence nobody can review. disproportionate_effort additionally generates the public-communication obligation, because Art. 34(3)(c) substitutes a public communication rather than removing the duty. |
| Art. 56 one-stop-shop |
PersonalDataBreach.lead_authority naming the lead supervisory authority, with Incident.cross_border_impact driving templates marked requires_cross_border. Deliberately distinct from cross_border_eu, which is Art. 4(23) cross-border processing. |
| Obligation | Satisfied by |
|---|---|
| NIS2 Art. 23(1) inform the recipients of the service |
regime = nis2_recipients, recipient_kind = customer, no_fixed_deadline = True. |
| NIS2 Art. 23(3) significance test |
Incident.is_significant (three-state), significance_determined_at (usable as a ClockAnchor) and significance_justification. A null verdict never silently generates or suppresses a NIS2 duty. |
| NIS2 Art. 23(4)(a) 24-hour early warning, stating whether the incident is suspected of being caused by an unlawful or malicious act and whether it has cross-border impact |
regime = nis2_early_warning, anchored on awareness, 24 hours, requires_significant. The drafted -> sent gate is refused while is_significant, suspected_malicious or cross_border_impact is null : the form the operator is filing has a mandatory field for each, so the record must be able to answer them. |
| NIS2 Art. 23(4)(b) 72-hour incident notification |
regime = nis2_notification, anchored on awareness, 72 hours. |
| NIS2 Art. 23(4)(c) intermediate report on request |
regime = nis2_intermediate, no_fixed_deadline = True, source = manual, created when the authority asks. Deliberately not seeded : generating it speculatively would put a permanent open obligation on every significant incident and train the operator to ignore the bucket. |
| NIS2 Art. 23(4)(d) final report within one month of the incident notification |
regime = nis2_final, clock_anchor = previous_stage, depends_on the nis2_notification obligation, 720 hours. Its due_at stays null until that filing is actually made and appears the moment it is. Anchoring it on awareness instead would make every NIS2 final-report deadline in the register wrong, always in the direction that makes the organisation look later than it is. |
| DORA Art. 19 initial, intermediate and final major ICT incident reports |
regime = dora_initial / dora_intermediate / dora_final on the identical mechanism, with origin_supplier and affected_suppliers carrying the ICT third-party dimension and sub-processors reachable as ordinary Supplier rows. |
| ePrivacy Art. 4(3), CRA Art. 14 | Expressible with the same template shape as regimes eprivacy and cra : available, not shipped as defaults. |
| Contractual and internal duties |
contractual_customer, contractual_supplier, insurer, internal_management and public_communication run through the identical clock machinery, so a contractual 48-hour clause is tracked exactly like a statutory one. |
-
Automated RTO / MTD breach detection (RG-INC-33).
assets.EssentialAsset.max_tolerable_downtime,recovery_time_objectiveandrecovery_point_objectiveare free-textCharField(max_length=100)values such as4 hours. The register reports the measuredoutage_durationand each affected asset's declared objective verbatim, side by side, and declines to conclude. Migrating those three fields toDurationFieldis an m2 prerequisite. - Business-day and public-holiday clock semantics. All arithmetic is wall-clock, which is correct for GDPR, NIS2 and DORA : the 72 hours of Art. 33(1) run through nights, weekends and public holidays. A contractual clause written in business days cannot be expressed by this model and is stated as out of scope rather than approximated.
- A maintained European authority directory. The seeded CNIL and ANSSI rows are demo data for the Voltara Energy dataset so the screenshots show a real portal link. An organisation outside France writes its own rows, which is exactly why the catalogue is a table and not a hardcoded matrix.
A new Django app touches a fixed set of global registration points, and the count is independent of the data model. Every item below is required, and the four marked (silent) fail without an error message.
Application and data
-
INSTALLED_APPSgains"incidents"incore/settings.py. - (silent)
incidents/apps.pydefinesIncidentsConfig.ready()and importsincidents.lifecyclesthere.lifecycle_name_for()resolvesLIFECYCLE_NAMEonlyif name and name in LIFECYCLE_REGISTRY, so omitting the import quietly downgrades every model to the core 4-state lifecycle, in tests as well as in production. A test assertsIncident.get_lifecycle().name == "incident"per entity so the omission fails loudly. -
incidents/admin.pyregisters every model withSimpleHistoryAdmin. - Initial migration, plus the migration deleting the reserved
# linked_incidents = ...placeholder onrisks.Risk. -
scripts/seed_demo_data.pygains an incidents phase exercising every model and field, including the two authorities and the seeded template set, each created throughsave()thentransition_to(..., enforce_permission=False).
Permissions
-
PERMISSION_REGISTRYgains theincidentsmodule with its six features, andMODULE_LABELSgains"incidents": _("Incidents"). -
accounts/migrations/0056_add_incidents_permissions.pycreates the rows and attaches them to the six system groups.
Navigation and theming
-
core/navigation.pyNAV_TREEgains the module's sections and pages. -
core/templatetags/ui.pyMODULE_ACCENTSgains"incidents". -
base.htmldefines--module-accent-incidentsand--module-accent-incidents-softin both the light and the dark token block. An unregistered accent is silently dropped. - The sidebar gains its section between Risk management and Compliance.
Cross-cutting surfaces
-
GlobalSearchView:NAVIGATION_ENTRIES,ACTION_ENTRIESand the search categories forIncidentandSecurityEvent. - Calendar :
ALL_CATEGORIES, theadd()/add_range()blocks for the five date sources, and the label inbuild_upcoming_deadlines. - Kanban :
ENTITY_PERMS,TYPE_ICONS,TYPE_LABELS,_INCIDENT_BUCKETS,_build_incidentsand its_BUILDERSregistration. - Dashboard : the widget in
DASHBOARD_WIDGETS, its partial undertemplates/dashboard/widgets/, the entry incore/signals.py_DASHBOARD_MODELSfor cache invalidation, and the context variables the partial reads, set inGeneralDashboardView.get_context_data. A registered widget whose context variables are never populated renders empty with no error. -
context.PredefinedIndicatorSourcegainsincidents_per_period,mean_time_to_detect,mean_time_to_contain,mean_time_to_resolve,open_incidents,overdue_notificationsandunsealed_evidence, with matching_compute_*methods andPREDEFINED_SOURCE_FORMATentries.
Integration surfaces
-
mcp/tools.py:_register_incidents_tools()added toregister_all_tools(), theHELP_TEXTreference-prefix entries, andTOPIC_INCIDENTSadded toALL_TOPICSand to the help tool's description andtopicproperty. -
mcp/tools.py_filter_by_scopes()and the_register_crud()handler chain extended withscope_parent_lookup(§5), plus the two generic web endpoints. -
assistant/catalog.py: the four read-onlyToolSpecentries. -
accounts/notifications.py: the eightNotificationTypevalues, theirnotify_*helpers and the accounts migration altering the choices. -
helpers: a help-content migration adding the module's help banners.
Documentation and translation
-
locale/fr/LC_MESSAGES/django.po: every new string, withmsgctxt "incident"blocks for the collisions listed in §10.3, and a verification pass for duplicatemsgidentries before commit. -
docs/specs/README.mdlayout table gains them6-incidents/row;docs/user-guide/,docs/reference/rest-api.mdanddocs/reference/mcp-server.mdare updated. -
README.md: the feature table, the MCP tools section and the module list reflect the new module. -
CHANGELOG.md: an### Addedentry for the module, a### Securityentry for the scope-inheritance fix, and a### Changedentry for thecompliance.findingpermission re-gating.
These are stated plainly because an implementer and an auditor both need them, and because discovering any of them during an inspection is worse than reading it here.
Append-only is an application-level guarantee, not a database one. IncidentTimelineEntry, EvidenceCustodyEvent and NotificationFiling refuse updates and deletions in save() and delete(). 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 covered. QuerySet.update(), QuerySet.bulk_update(), QuerySet.delete(), cascade deletion, raw SQL and a manage.py shell session are not. HistoricalRecords turns prevention into detection : a row whose historical trail shows more writes than the design allows has been altered, and that is visible on its history panel. 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 here; if it is ever taken it must be taken deliberately and documented in this file. The claim to make to an auditor is tampering is prevented on every supported path and detectable on the rest, never the ledger is immutable.
Three overlapping audit trails. See §10.4 for what each is authoritative for. The residual risk is a narrative hole : any future code path that assigns workflow_state directly instead of calling transition_to(), and any bulk import that writes 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, which is why the register export carries a reconciliation line and why direct workflow_state assignment is forbidden in review.
Gates live in transition_to(), so an administrator can add an edge the gate does not know about. RG-INC-08 is the only working choice, because form_class, allowed_roles and allowed_users are dropped by the LifecycleDefinition round-trip. The cost is that an administrator editing the lifecycle at /config/lifecycles can add, say, a triaged -> closed edge. The required-field, review-approved, obligations-decided and evidence-sealed checks would still run inside transition_to(), but a step the gate does not enumerate could be reached, and the transition's permission_action would then be the only surviving control on that path. Marking a lifecycle is_customized also detaches it permanently from the code definition, and a migrate run is required after every incidents/lifecycles.py edit before a change takes effect. A lifecycle-definition validator that refuses to save an edge the module declares as gate-bearing is a worthwhile follow-up and is not in this module.
The template applicability engine is a flat conjunction. ReportingObligationTemplate evaluates its conditions as an AND, in a fixed order. Real regulatory rules are disjunctive (significant or affecting more than N users) and conditional (unless the data was encrypted). The gap is paid for with near-duplicate templates rather than with a rule expression language, and with the deliberately stricter treatment of negative conditions : the obligation is still generated and the exemption is discharged through its own approve-gated not_required decision. A rule language would need a parser, an evaluator, a test surface, an editing UI, a migration path for stored expressions and a way to explain to an operator at 02:00 why a rule did or did not fire. It is reconsidered only if a real regime cannot be expressed at all, not because expressing it takes three templates instead of one.
The ThreatCategory reuse is a real coupling. Incident.category and SecurityEvent.category reuse risks.constants.ThreatCategory (23 values) verbatim, so that the incident, threat and risk chain reads as one taxonomy. The ENISA and ISO/IEC 27035-2 incident taxonomies do not map onto those values one-to-one : there is no clean misconfiguration or third-party outage bucket. A regulator demanding its own scheme forces either a second field or a mapping table, and adding an incident-specific value means editing risks/constants.py and accepting that the value then also appears in every threat picker. The alternative, two taxonomies, drifts within one release and breaks the chain, so this coupling is chosen knowingly.
Two further consequences worth knowing. IncidentResponseAction runs a plain status column rather than a lifecycle, which is an argued deviation from the platform doctrine : it is invisible to reportable() / linkable() / deletable_states(), cannot be governed per state, emits no LifecycleEvent, and would need a data migration on a live table if the decision is ever reversed. And the platform holds, for any evidence item registered by reference, the hash of something it does not hold : Cairn can prove that the digest recorded at acquisition has not been altered inside Cairn, and nothing about the artefact sitting in a vault. The UI renders Held in Cairn and Registered by reference as two visually distinct states precisely so a reader of a green integrity column knows which of the two claims it is making.
- An event can be reported (including anonymously), assessed with mandatory notes, and promoted, confirmed as a weakness or discarded, each through a permissioned transition; a discarded event is still findable with its rationale.
- An incident runs the full lifecycle with write-once phase stamps, and cannot be closed until its review is approved, every obligation is decided and every evidence item has left
collected. -
awareness_atdrives every statutory deadline, defaults todetected_at, and requires a justification whenever it postdates detection. - Triage instantiates obligations in
assessedwith a matchingLifecycleEvent, is idempotent when re-run, and refuses to complete with zero obligations unless personal data is involved or the incident is an exercise. - Evidence can be registered, sealed, analysed, retained, released and destroyed; sealed acquisition metadata refuses to change; destruction is a transition that keeps the row and appends a final custody event.
- Integrity verification reports three distinct outcomes and never collapses not verifiable into mismatch.
- A personal data breach can be confirmed only with the complete Art. 33(3) set and a non-null Art. 34 verdict, and is ruled out through a transition, never by clearing a checkbox.
- A filing freezes
first_submitted_at,late_byandwas_late, starts any dependent clock, and cannot be un-breached by a later anchor correction. - An exercise runs the identical lifecycle, generates no obligation, appears in no KPI or deadline feed, and updates the plan's
last_exercise_dateon closure.
- All thirty codenames exist, are granted to the six system groups by the accounts migration, and appear on the group matrix screen.
- Every lifecycle-bearing model resolves the expected lifecycle by name (the test that catches a missing
ready()import). - No archive -> restore -> delete path exists on any entity : the restore edge is approve-gated and refused for any row that ever left
draft, and the regression test asserting a sealed evidence row survives the attempt passes. - No state literal appears outside
incidents/constants.py.
- A user scoped out of an incident gets an empty list from every child-entity MCP tool, a 404 from
workflow:transitionand a 404 from the history partial for its evidence, obligations, custody rows and breach record. - Evidence bytes and proof bytes are reachable only through the permission-checked and scope-checked detail actions, never through
/media/and never in a list or detail payload. - The upload caps are enforced in the form, the serializer and the MCP layer.
-
compilemessagessucceeds : no duplicatemsgidwithout a distinguishingmsgctxt, and no step label carrying amsgctxt. - Every detail page renders the lifecycle stepper, in a 2-column layout with no nav-tabs, correctly in light and dark mode and at mobile widths, with the three- and four-exit graphs and the confirmation modal explicitly checked.
-
ruff checkandpytest -x --covpass, and the seed produces a demo dataset exercising every model and field.
End of Module 6 : Security Incident Management
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)