-
Notifications
You must be signed in to change notification settings - Fork 2
Specs M6 Incidents Incident Notification
incidents.models.notification.IncidentNotification
One regulatory or contractual notification obligation : exactly one row per (incident, regime, recipient) triple. The obligation is the unit of record, not the message. A row exists as soon as the obligation is conceivable, carries the legal clock, carries the decision on whether it applies, and carries the trail of what was actually filed against it.
File : incidents/models/notification.py
BaseModel subclass : UUID PK, sequential reference (prefix INOT, e.g. INOT-1), tags, version, created_by, django-simple-history audit trail, and the dedicated incident_notification lifecycle. workflow_perm_namespace is overridden to incidents.notification : the default app_label.model_name would spell incidents.incidentnotification, which matches no feature in PERMISSION_REGISTRY, and every transition would then be refused for everyone. It is not a ScopedModel : it inherits the incident's scope through scope_parent_lookup = "incident__scopes" (see Scope and tenancy).
Phase 1 ships the entity with a flat clock (deadline_hours counted from Incident.awareness_at). Phase 2 grafts the anchor engine onto this same table rather than introducing a parallel obligation entity, so no live row is ever migrated and the platform never grows a second answer to what do we owe, to whom, by when. Fields belonging to phase 2 are marked as such in the tables below.
This is the argument the whole entity is built on, and it is worth stating before the fields.
GDPR Art. 33(1) does not say notify the supervisory authority. It says notify unless the breach is unlikely to result in a risk to the rights and freedoms of natural persons. The omission is not the absence of an act : it is a legal act, taken under a derogation the controller must be able to justify, and Art. 33(5) requires it to be documented well enough for the authority to verify compliance. NIS2 Art. 23 and DORA Art. 19 work the same way : an incident that is judged not significant produces no filing, and the judgement is the thing the regulator inspects.
An obligation that was correctly not notified therefore has to leave a record with four elements, all four of them:
- a named decider (
decided_by) who held the permission to decide; - a timestamp (
decided_at) that can be compared against the clock; - a written rationale (
decision_rationale) - the single most audited sentence in a breach file; - an approval, in the sense that reaching the omission step requires
incidents.notification.approveand a mandatory transition comment, so a responder cannot quietly rule out an obligation alone at 02:00.
A boolean column, a nullable sent_at, or a filing table with no row in it can carry none of those. That is why the omission is a terminal lifecycle step (not_required) reached through an approve-gated, comment-bearing transition, and not a field write.
The same reasoning explains why obligations are instantiated at triage rather than when someone decides to file. An obligation nobody has thought about must be visible rather than absent : it sits in assessed ("To decide"), it appears in the incident's notification card, in the "To decide" bucket of the list, and it blocks closure through RG-INC-14. A design that creates the row only when the notification is drafted cannot distinguish we considered GDPR Art. 33 and concluded it did not apply from nobody looked, which is precisely the distinction an inspection turns on.
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
UUID | PK, auto-generated | Unique identifier |
reference |
string | auto-generated INOT-N, unique |
Business reference, citable in a filing and in correspondence with the authority |
regime |
enum | required, max 32 |
NotificationRegime. The legal or contractual basis of the obligation. Snapshotted from the template in phase 2 (RG-INC-30). |
recipient_kind |
enum | required, max 25 |
NotificationRecipientKind. Class of recipient. |
recipient_name |
string | optional, max 255, blank default | Free-text recipient when it is not a registered stakeholder, supplier or authority. Required when every recipient FK is null and the obligation is not addressed to an authority of record. |
recipient_key |
string | max 255, blank default, derived | Non-nullable recipient discriminator computed in save(), used by the uniqueness constraint. See Uniqueness of an obligation. Never edited, never exposed as a writable field. |
obligation_reference |
string | optional, max 255, blank default | The cited article, e.g. GDPR Art. 33(1), NIS2 Art. 23(4)(a). Snapshotted from the template's legal_reference in phase 2. |
content_requirements |
text | optional, HTML, blank default | The legal checklist of what the filing must contain, rendered beside the drafting field so the drafter never leaves the page to find it. Snapshotted in phase 2. |
deadline_hours |
int | optional, PositiveIntegerField
|
Statutory delay in wall-clock hours from the anchor. Typical values 24, 72, 720. Null if and only if no_fixed_deadline is True. |
no_fixed_deadline |
boolean | required, default False
|
The obligation is a "without undue delay" duty with no numeric limit (GDPR Art. 33(2), Art. 34(1), NIS2 Art. 23(1) to recipients). due_at stays null, the row is never counted late, and it surfaces in its own bucket. Never fabricate a deadline for an obligation that legally has none.
|
clock_anchor |
enum | required, default awareness_at, max 30 |
Phase 2. ClockAnchor : which timestamp starts the clock. Phase 1 behaves as if this were always awareness_at. |
anchor_at |
datetime | optional, indexed |
Phase 2. The resolved anchor timestamp actually used, stored so the derivation is auditable and historised rather than re-derived by today's code against today's data. Frozen with due_at at the first filing. |
due_at |
datetime | optional, indexed | The statutory deadline. Recomputed in save() from the anchor plus deadline_hours only while first_submitted_at is null; never editable directly on any surface. Stored rather than derived so lateness is filterable, sortable, indexable and reachable from the calendar, the kanban, the escalation command and MCP. |
decision |
enum | required, default undecided, max 15 |
NotificationDecision. Mirrors the lifecycle step and is set by the transition_to() override, kept as a column so filters, list facets, the closure gate and MCP enums never have to read the lifecycle. |
decision_rationale |
text | optional, blank default | Required non-blank when decision = not_required (DB CheckConstraint and the transition's requires_comment). The Art. 33(1) justification. |
decided_by |
relation | FK -> User, SET_NULL, optional, related_name="incident_notification_decisions"
|
Who took the decision. Stamped by the transition, never by a form. |
decided_at |
datetime | optional, write-once | When the decision was taken. Stamped by the transition. |
channel |
enum | optional, blank default, max 20 |
NotificationChannel : how the notification was actually transmitted. Declared once in incidents/constants.py and shared with NotificationFiling, with the same max_length on both fields. |
content |
text | optional, HTML, blank default | The exact text transmitted (GDPR Art. 33(3) minimum content : nature, DPO contact, likely consequences, measures taken). Write-once once sent_at is set.
|
sent_at |
datetime | optional, write-once | Transmission timestamp, stamped by the filing transition. Must be <= now : a filing cannot be recorded in the future. |
sent_by |
relation | FK -> User, SET_NULL, optional, related_name="sent_incident_notifications"
|
Who filed it. |
first_submitted_at |
datetime | optional, write-once |
Phase 2. Stamped by the first NotificationFiling. Once set, anchor_at and due_at stop recomputing for good. |
late_by |
duration | optional, write-once |
Phase 2. DurationField. Frozen lateness at the first filing : first_submitted_at - due_at when positive, otherwise null. Null when no_fixed_deadline. The frozen breach record. |
acknowledgement_reference |
string | optional, max 255, blank default | The authority's case, ticket or receipt number. Required to reach acknowledged. |
acknowledged_at |
datetime | optional | When receipt was confirmed by the recipient. |
proof_file_content |
binary | optional, editable=False
|
The filed document or the portal receipt, stored as bytes following Contract.file_content, Certificate.file_content and TrustCenterDocument.file_content, so backup and restore stay a single source of truth. Capped by INCIDENT_NOTIFICATION_MAX_PROOF_BYTES. Excluded from list serializers, from MCP list_fields and from HistoricalRecords. Not base64 in a TextField, which would bloat the row, the historical table and every API and MCP payload. In phase 2 the authoritative proof moves to the filing; see Relationship with NotificationFiling. |
proof_filename |
string | optional, max 255, blank default | Original filename of the proof. |
source |
enum | required, default auto, max 10 |
ObligationSource : whether the row was generated from the plan or the template, or added by hand. Governs deletability (see Deleting an obligation). |
workflow_state |
string | indexed, default draft
|
Lifecycle step (incident_notification) |
tags |
relation | M2M -> context.Tag
|
|
version |
int | auto-incremented | |
created_by |
relation | FK -> User | |
created_at / updated_at
|
datetime | auto | Timestamps |
history |
HistoricalRecords(excluded_fields=["proof_file_content"]) |
Audit trail. Every deadline recomputation is historised, so the derivation of a due date is reconstructable months later. |
| Name | Type | Target | Reverse accessor | Description |
|---|---|---|---|---|
incident |
FK, PROTECT, required |
Incident | notifications |
The incident the obligation arises from. PROTECT : an incident that owes, or owed, a regulator anything can never be deleted. |
recipient_stakeholder |
FK, SET_NULL, optional |
context.Stakeholder |
incident_notifications |
Registered stakeholder recipient, reusing the existing stakeholder register rather than retyping a contact. |
recipient_supplier |
FK, SET_NULL, optional |
assets.Supplier |
incident_notifications |
Supplier recipient. Also the controller we must notify under GDPR Art. 33(2) when we act as processor. |
authority |
FK, PROTECT, optional |
ReportingAuthority | obligations |
Phase 2. The body the filing goes to, with its portal, mailbox and procedure. |
template |
FK, PROTECT, optional |
ReportingObligationTemplate | obligations |
Phase 2. The rule this obligation was generated from. PROTECT : a template with instantiated obligations can never be deleted. The obligation's legal terms are snapshotted, not read through this FK (RG-INC-30). |
depends_on |
FK -> self, SET_NULL, optional |
IncidentNotification | dependents |
Phase 2. The sibling obligation whose filing anchors this one. See The clock model. |
proof_evidence |
FK, SET_NULL, optional |
IncidentEvidence | notification_proofs |
Used when the receipt is itself registered as evidence under A.5.28. |
filings |
reverse FK | NotificationFiling | Phase 2. The append-only log of actual transmissions against this obligation. |
ordering = ["incident", "due_at"]-
Index(fields=["due_at", "workflow_state"]): the index the "are we late" query runs on, on the list page, in the calendar, in the dashboard widget and in the daily escalation command. UniqueConstraint(fields=["incident", "regime", "recipient_key"], name="unique_notification_per_incident_regime_recipient")-
CheckConstraint notification_not_required_has_rationale:~Q(decision="not_required") | ~Q(decision_rationale="") -
CheckConstraint notification_deadline_hours_xor_no_fixed_deadline:Q(no_fixed_deadline=True, deadline_hours__isnull=True) | Q(no_fixed_deadline=False, deadline_hours__isnull=False) -
CheckConstraint notification_pending_anchor_only_for_previous_stage(phase 2) : an obligation may carryno_fixed_deadline=Falsewith a nulldue_atonly whenclock_anchor = previous_stage. Every other combination of a numeric deadline with no due date is a bug, and the database says so.
The obvious constraint - UniqueConstraint(["incident", "regime", "recipient_stakeholder", "recipient_supplier", "recipient_name"]) - does not prevent the duplicate it is written for, and the module does not use it.
In PostgreSQL, NULLs are distinct inside a unique index by default. On every auto-generated authority obligation both recipient FKs are null, so two identical gdpr_art33_authority rows on the same incident do not collide : the index treats them as different keys and inserts both. This is not a theoretical case. Obligation generation deliberately re-runs at three points : at triage, on INCIDENT_SEVERITY_RAISED (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), and on confirmation of a personal data breach (which is when controller_role and the Art. 34 high-risk verdict become known). Each re-run would silently add a second copy of every authority obligation, and the incident would then show two 72-hour GDPR clocks, one of which nobody is watching.
Two fixes are needed, and both are taken :
-
A non-nullable discriminator.
recipient_keyis computed insave()and holdsstakeholder:<uuid>,supplier:<uuid>,authority:<uuid>,name:<casefolded recipient_name>, or""when the obligation is addressed to the regime's authority of record with no authority row yet (the phase-1 shape). The unique constraint is then over three non-nullable columns and behaves identically on PostgreSQL and on the SQLite the test suite runs against.nulls_distinct=False(PostgreSQL 15+) would be a shorter fix, but Django raisesmodels.W047and drops the clause on backends that do not support it, socore.settings_testcould never exercise it : the constraint would be untested precisely where the regression would be introduced. -
An idempotent generator. The constraint is the last line of defence, never the mechanism.
generate_obligations()resolves each candidate through an explicitget_or_create-style lookup on(incident, regime, recipient_key), updates nothing on an existing row, and returns the set of rows it actually created. Relying on the constraint alone would turn a re-run into anIntegrityErrorin the middle of a severity-raise save, which is a worse failure than the duplicate.
The regression test asserts that running the generator twice over the same incident leaves the obligation count unchanged and creates no second core.LifecycleEvent.
Reproduced verbatim from incidents/constants.py (DB value = Label).
| Value | Label |
|---|---|
gdpr_art33_authority |
GDPR Art. 33(1) - supervisory authority |
gdpr_art34_data_subject |
GDPR Art. 34 - data subjects |
gdpr_art33_2_controller |
GDPR Art. 33(2) - controller |
nis2_early_warning |
NIS2 Art. 23(4)(a) - early warning |
nis2_notification |
NIS2 Art. 23(4)(b) - incident notification |
nis2_intermediate |
NIS2 Art. 23(4)(c) - intermediate report |
nis2_final |
NIS2 Art. 23(4)(d) - final report |
nis2_recipients |
NIS2 Art. 23(1) - recipients of the service |
dora_initial |
DORA Art. 19 - initial report |
dora_intermediate |
DORA Art. 19 - intermediate report |
dora_final |
DORA Art. 19 - final report |
eprivacy |
ePrivacy Directive Art. 4(3) |
cra |
Cyber Resilience Act Art. 14 |
sector_regulator |
Sector regulator |
law_enforcement |
Law enforcement |
cert_csirt |
CERT / CSIRT |
contractual_customer |
Contractual - customer |
contractual_supplier |
Contractual - supplier |
insurer |
Insurer |
internal_management |
Internal management |
public_communication |
Public communication |
other |
Other |
| Value | Label |
|---|---|
supervisory_authority |
Supervisory authority |
csirt |
CSIRT |
competent_authority |
Competent authority |
financial_regulator |
Financial regulator |
law_enforcement |
Law enforcement |
data_subject |
Data subject |
customer |
Customer |
controller |
Controller |
supplier |
Supplier |
insurer |
Insurer |
internal |
Internal |
public |
Public |
| Value | Label |
|---|---|
undecided |
Undecided |
required |
Required |
not_required |
Not required |
Shared with NotificationFiling.
| Value | Label |
|---|---|
portal |
Portal |
email |
|
postal |
Postal mail |
phone |
Phone |
api |
API |
in_person |
In person |
public_notice |
Public notice |
Phase 2.
| Value | Label |
|---|---|
occurred_at |
Occurrence |
detected_at |
Detection |
awareness_at |
Awareness |
significance_determined_at |
Significance determination |
previous_stage |
Previous filing |
| Value | Label |
|---|---|
auto |
Generated |
manual |
Added manually |
Everything about statutory timing lives here, in one place, so an implementer never has to reconstruct it from three files.
The first four ClockAnchor values are, deliberately, the exact field names on Incident : occurred_at, detected_at, awareness_at, significance_determined_at. Resolution is therefore a getattr(self.incident, self.clock_anchor) with no mapping dictionary to drift out of date, and a grep for the enum value finds both the constant and the field it reads. previous_stage is the one value that is not an incident field, which is exactly why it needs its own branch and its own foreign key.
def resolve_anchor(self):
if self.first_submitted_at: # frozen : never recompute a filed obligation
return self.anchor_at
if self.no_fixed_deadline:
return None
if self.clock_anchor == ClockAnchor.PREVIOUS_STAGE:
return self.depends_on.first_submitted_at if self.depends_on_id else None
return getattr(self.incident, self.clock_anchor)awareness_at is the default and is the anchor of every GDPR and NIS2 obligation in the shipped catalogue. It is not detected_at : anchoring a statutory deadline to technical detection is legally wrong, and it is the first thing an inspector attacks. The distinction, its justification field and its rules live on Incident (RG-INC-13).
save() sets anchor_at = resolve_anchor() and, when both anchor_at and deadline_hours are known, due_at = anchor_at + timedelta(hours=deadline_hours). 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 (RG-INC-33 records the same discipline for RTO claims).
due_at is stored, not computed on read. A property would be unfilterable, unsortable, unindexable and invisible to the calendar, the kanban, the dashboard widget, the daily escalation command and MCP - which is to say invisible everywhere the deadline actually matters. It is never editable directly : it carries no form field, is read_only in every serializer and is absent from every MCP writable_fields list.
NIS2 Art. 23(4)(d) gives one month for the final report, counted from the incident notification of Art. 23(4)(b), not from awareness. An implementation that anchors it on awareness produces a deadline that is wrong by however long the 72-hour notification actually took, on every single NIS2 incident in the register, always in the direction that makes the organisation look later than it is.
The nis2_final obligation therefore carries clock_anchor = previous_stage and depends_on pointing at the nis2_notification obligation on the same incident. Its anchor_at is that row's first_submitted_at, and it stays null until the 72-hour notification is actually filed. Filing an obligation therefore recomputes its dependents in the same transaction.
An obligation with a null due_at is not one thing but two, and collapsing them is how a real deadline disappears from a dashboard :
| Bucket | Condition | Behaviour |
|---|---|---|
| Dated |
due_at is not null |
Counted in the overdue query, shown with a live countdown, swept by escalate_incident_deadlines
|
| No statutory deadline |
no_fixed_deadline = True, due_at null |
Never counted late. Surfaced in its own "no statutory deadline" bucket with its own badge, never hidden from the list. NOTIFICATION_DEADLINE_APPROACHING never fires; the obligation is still blocking closure until decided. |
| Deadline pending |
no_fixed_deadline = False, due_at null, clock_anchor = previous_stage, depends_on not yet filed |
Shown as pending : starts when INOT-N is filed, with a link to the obligation it waits on. Explicitly not merged into the bucket above : the deadline exists, it has simply not started. The notification_pending_anchor_only_for_previous_stage constraint keeps any other row out of this bucket. |
Overdue is always derived, never stored : due_at < now() AND sent_at IS NULL AND the step is not terminal. It is a query, so it is right the instant the clock passes, and there is no status column to fall out of date.
Lateness, once a filing exists, is frozen forever. At the first filing, in one transaction, the obligation stamps first_submitted_at, computes late_by (the positive part of first_submitted_at - due_at, null when there is no due_at), and NotificationFiling.was_late records the same verdict on the filing itself. After that, save() stops recomputing anchor_at and due_at entirely.
This is not an optimisation, it is the point. awareness_at stays editable after triage, because facts change : a forensic finding can move the moment the organisation is deemed to have become aware. Without the freeze, correcting the anchor forward by six hours would silently move due_at past a filing that was late when it was made, and an obligation that breached the 72-hour limit would quietly stop having breached it, with nothing in the record to show it ever did. With the freeze, the correction is recorded on the incident and historised, the filed obligation keeps its late_by, and a material change to the facts is communicated the way the law expects : as a correction filing on the same obligation (see NotificationFiling), not as a retroactive edit.
While first_submitted_at is null, anchor_at and due_at are recomputed on : any save of the obligation; a change to the anchoring field on the incident (propagated by Incident.save()); the severity raise that re-runs generation; the confirmation of a PersonalDataBreach; and the filing of an obligation this one depends_on. Every recomputation goes through a per-row save(), never QuerySet.update(), so HistoricalRecords captures each one and the derivation of a due date is reconstructable from the history alone.
LIFECYCLE_NAME = "incident_notification", layout="graph", generated by lifecycle_from_state_flags() in incidents/lifecycles.py from the state and transition constants in incidents/constants.py, and registered from IncidentsConfig.ready(). Unlike incident and incident_evidence, this lifecycle needs no step trigger, so it keeps the generated form the project's rules prescribe. It declares both bookend steps explicitly - see below.
| Code | Label | StepKind | In reports | Linkable | Deletable | Tone | Meaning |
|---|---|---|---|---|---|---|---|
draft |
Draft | DRAFT |
no | no | yes | neutral |
Being written up by hand; not yet an obligation of record |
assessed |
To decide | INTERMEDIATE |
yes | no | yes | warning |
The obligation exists and no decision has been taken. This is the state the whole "an unanswered obligation is visible rather than absent" argument rests on, and the state the closure gate refuses. |
required |
Required | INTERMEDIATE |
yes | no | no | info |
Decided to apply; decision = required
|
drafted |
Drafted | INTERMEDIATE |
yes | no | no | primary |
The filing text is written and under review |
sent |
Sent | INTERMEDIATE |
yes | no | no | success |
Transmitted; sent_at, first_submitted_at and late_by are frozen |
acknowledged |
Acknowledged | INTERMEDIATE |
yes | no | no | dark |
Receipt confirmed by the recipient, with a case number |
not_required |
Not required |
ARCHIVED (terminal) |
yes | no | no | muted |
The Art. 33(1) omission, decided, justified and approved. This is the step an auditor asks to see. |
archived |
Archived | ARCHIVED |
no | no | no | muted |
The generic exit, declared explicitly |
not_required keeps counts_in_reports = True, unlike the discarded step of SecurityEvent. The difference is deliberate : a false-positive event is not a security event of record, whereas an obligation ruled out is part of the compliance record and must appear in the incident register, the notification report and any Art. 33(5) extract. An omission that vanishes from reports is an omission nobody can review.
permission_action is appended to workflow_perm_namespace (incidents.notification).
| Verb | Transition | permission_action |
requires_comment |
Side effects |
|---|---|---|---|---|
| Open obligation | draft -> assessed |
update |
no | Hand-declared. The step every generated obligation is moved to immediately after insert. |
| Confirm required | assessed -> required |
update |
no | Sets decision = required; stamps decided_by and decided_at
|
| Decide not to notify | assessed -> not_required |
approve |
yes | Sets decision = not_required; writes the mandatory comment into decision_rationale; stamps decided_by and decided_at
|
| Draft the filing | required -> drafted |
update |
no | |
| Record submission | drafted -> sent |
update |
no | Stamps sent_at and sent_by; content becomes write-once; freezes first_submitted_at and late_by; creates the first NotificationFiling (phase 2) |
| Record submission made outside Cairn | required -> sent |
update |
no | Same side effects. The path for a filing made on a portal at 03:00 and recorded afterwards. |
| Record acknowledgement | sent -> acknowledged |
update |
no | Requires acknowledgement_reference; sets acknowledged_at
|
| Reopen for correction | sent -> drafted |
update |
yes | Phase 1 only. Phase 2 replaces this with an additional filing carrying is_correction = True. |
| Authority requested more information | acknowledged -> drafted |
update |
yes | Phase 2 records the request and the response as successive filings on this same obligation |
| Archive | * -> archived |
approve |
yes | Hand-declared, not auto-wired |
| Restore | archived -> draft |
approve |
no | Hand-declared, and additionally gated by G-07 |
Recording a filing needs only update (RG-INC-26). This is a deliberate asymmetry with the omission path : putting an approver in the loop on a 24-hour NIS2 clock manufactures late filings, and a late filing is a breach while an early one never is. Only the transitions that declare an obligation extinguished - not_required, and the archive bookend - require approve.
Both bookend edges are hand-declared, and so is
draft -> assessed.lifecycle_from_state_flags()auto-wiresdraft -> <initial step>,ANY -> archivedandarchived -> draftonly when the corresponding step is absent from the state-flag list (core/lifecycle.pylifecycle_from_state_flags()). The auto-wired archive and restore edges carry nopermission_actionand norequires_comment, anduser_can_perform()(core/lifecycle.pyuser_can_perform()) allows any transition whosepermission_actionis empty. Left generated, they would give anyone able to reach the transition endpoint an archive -> restore -> delete path out of an obligation sitting inrequiredor evensent: a regulatory obligation, including the record of a decision not to notify, could be erased by a user holding no approve permission at all. This lifecycle therefore declaresdraftandarchivedexplicitly so nothing is auto-wired, and lists all three edges inNOTIFICATION_TRANSITIONSwith explicit actions.
Per RG-INC-08, every gate below lives in a transition_to() override on IncidentNotification, never in Transition.form_class, allowed_roles or allowed_users. lifecycle_to_json() (core/lifecycle.py lifecycle_to_json()) omits those three fields 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 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(), mcp/tools.py _transition_handler()), so the model override is the one place that binds web, API and MCP at once. Each gate raises a translated ValidationError naming the missing precondition, and the whole transition body runs inside transaction.atomic().
| Gate | Transition | Refused unless |
|---|---|---|
| G-01 Named omission (RG-INC-25) | assessed -> not_required |
The actor holds incidents.notification.approve and supplies a comment. The comment is persisted into decision_rationale, not only into the core.LifecycleEvent, so the register itself is readable without joining the history; a DB CheckConstraint refuses the row if it is blank. decided_by and decided_at are stamped from the actor and the transition time. |
| G-02 Filing preconditions |
drafted -> sent, required -> sent
|
channel is set, and either content is non-blank or a NotificationFiling is supplied with the transition. sent_at defaults to the transition time and is refused if it is in the future. |
| G-03 NIS2 early warning content (phase 2) |
drafted -> sent where regime = nis2_early_warning
|
Incident.is_significant, Incident.cross_border_impact and Incident.suspected_malicious are all non-null. Art. 23(4)(a) requires the early warning to state whether the incident is suspected of being caused by an unlawful or malicious act and whether it has cross-border impact; an early warning that cannot answer those two questions cannot be completed from the record, and the gate says so rather than filing a blank. |
| G-04 Acknowledgement | sent -> acknowledged |
acknowledgement_reference is non-blank. An acknowledgement with no case number is not an acknowledgement. |
| G-05 Content immutability (RG-INC-29) | all | Once sent_at is set, content, channel and sent_at are write-once : save() compares against the stored row and raises ValidationError on any attempted change. Write-once is prevented at application level and detected through HistoricalRecords; QuerySet.update(), bulk_update() and raw SQL bypass save(). An amendment is a new filing (phase 2) or a new obligation of the appropriate follow-up regime (phase 1), never a rewrite of what was transmitted. |
| G-06 Clock freeze (RG-INC-28) | all | Once first_submitted_at is set, anchor_at, due_at and late_by are never recomputed and are refused as writes. |
| G-07 Restore | archived -> draft |
No core.LifecycleEvent on this obligation records a step other than draft or archived. An obligation that ever reached assessed can be archived but never restored into a deletable step. Mirrors the Incident G-07 gate. |
| G-08 Write-once stamps (RG-INC-12) | all |
decided_at, sent_at, first_submitted_at and late_by are stamped by the override only : excluded from every ModelForm, read_only in every serializer, absent from every MCP writable list, and never cleared. |
BaseModel.save() calls _ensure_initial_step() (context/models/base.py BaseModel._ensure_initial_step()) on every insert, and Lifecycle.initial_step (core/lifecycle.py Lifecycle.initial_step) returns the single StepKind.DRAFT step. The workflow_state field default is the literal "draft", which is a valid step of this lifecycle, so _ensure_initial_step() leaves it alone and the row lands in draft.
An obligation is never "created in" assessed. Writing IncidentNotification.objects.create(..., workflow_state="assessed") would stick - the snap only fires on a blank or unknown value - but it would leave no core.LifecycleEvent row, so the obligation would have no recorded entry into the register, which is precisely the audit trail this entity exists to produce. And a row left in draft because nobody made the second call is worse than absent : draft is deletable=True, the row is missing from the "To decide" bucket the visibility argument rests on, and it still carries decision = "", so it blocks incident closure through RG-INC-14 with no explanation on any screen.
Every generation path therefore does, inside one transaction.atomic() block :
obligation, created = IncidentNotification.objects.get_or_create(
incident=incident,
regime=regime,
recipient_key=recipient_key,
defaults={...snapshotted legal terms...},
)
if created:
obligation.transition_to("assessed", user, enforce_permission=False)enforce_permission=False is correct here : the permission was already checked on the parent transition the user actually performed (the incident's detected -> triaged), and the obligations are a consequence of it, not a separate act by the user.
Generation runs at three points, and is idempotent at all three (see Uniqueness of an obligation) :
-
Triage (
detected -> triagedon the incident) : one obligation per applicable regime. Phase 1 readsIncidentResponsePlan.applicable_regimes; phase 2 matches ReportingObligationTemplate rows in areportable()lifecycle state and snapshots their legal terms onto each generated row (RG-INC-30).personal_data_involved = Trueforcesgdpr_art33_authorityregardless of configuration (RG-INC-18). An incident withis_exercise = Truegenerates nothing at all (RG-INC-17) : filing a real notification for a drill is an incident in its own right. -
Severity raise (
INCIDENT_SEVERITY_RAISED) : a raise can cross a template'smin_severityfloor and start a clock that did not exist before. Its absence is a missed 24-hour NIS2 deadline, which is why it is a trigger and not a nightly job. -
Personal data breach confirmation (phase 2) :
controller_roledecides which GDPR obligations exist at all - a processor owes Art. 33(2) to the controller, not Art. 33(1) to the supervisory authority - andhigh_risk_to_rightsdecides whether Art. 34 applies.
The module ships regression tests asserting that :
-
IncidentNotification.objects.create(...).workflow_state == "assessed"fails (the row lands indraft); - after
incident.transition_to("triaged", user), every row inincident.notificationsis inassessedand each has exactly one matchingcore.LifecycleEventwithto_step="assessed"; - running generation a second time (severity raise, then breach confirmation) leaves the obligation count and the
LifecycleEventcount unchanged.
draft and assessed are deletable = True so a manually added obligation typed in error can be removed without an approver. delete() is overridden to refuse any row with source = auto : a generated obligation is answered through not_required with a rationale, never deleted, because deleting it destroys the evidence that the organisation considered the regime at all. From required onward no step is deletable on any surface.
Phase 1 records the transmission on the obligation itself : channel, content, sent_at, sent_by, acknowledgement_reference, proof_file_content. That is enough for a single filing and nothing more.
Phase 2 adds NotificationFiling, the append-only log of actual transmissions, and the split becomes : the obligation carries the duty and the clock; the filing carries the act. GDPR Art. 33(4) phased provision and a NIS2 authority information request then become successive filings on one obligation rather than edits of the original or duplicate obligation rows, which is what keeps the 72-hour notification a single thing an auditor can point at.
The obligation-level fields are kept, not migrated : sent_at, channel and sent_by are mirrored from the first filing and stay the fast path for list rendering and filtering, and proof_file_content stays populated for rows filed before phase 2. The filing log is authoritative whenever the two could disagree, and the notification detail page renders the filing history rather than the mirrored fields as soon as more than one filing exists.
RG-INC-38. IncidentNotification is not a ScopedModel and never carries its own scopes. It inherits the parent incident's scope through scope_parent_lookup = "incident__scopes", so it can never drift out of alignment when the incident is re-scoped.
Scope inheritance for non-ScopedModel children is not currently enforced on three surfaces, and phase 1 extends all three. This is core work in the phase-1 PR, not an incidents-app detail, and it is logged under a ### Security entry in CHANGELOG.md.
| Call site | Today | Change |
|---|---|---|
mcp/tools.py _filter_by_scopes()
|
Handles context.Scope, then a direct scopes M2M, then returns the queryset unfiltered
|
Accept model / parent_lookup and thread a scope_parent_lookup argument through _register_crud / _list_handler / _get_handler / _transition_handler / _allowed_transitions_handler. Without it, list_incident_notifications and list_overdue_incident_notifications return every obligation on the instance - regime, recipient, deadline and omission rationale included - to any holder of incidents.notification.read. |
core/workflow_views.py WorkflowTransitionView
|
Guards with if allowed_scopes is not None and hasattr(obj, "scopes")
|
Honour a model-level scope_parent_lookup. Without it, the not_required decision - the GDPR Art. 33(1) omission - is performable cross-scope by a user with no access to the incident. |
core/history_views.py HistoryPartialView
|
Same hasattr(obj, "scopes") guard |
Same change. Without it, the full history of an out-of-scope obligation, including every version of its rationale, is readable. |
ScopeFilterMixin (accounts/mixins.py) and ScopeFilterAPIMixin (accounts/api/mixins.py) already support scope_parent_lookup, so list views and viewsets need only declare it.
| ID | Rule |
|---|---|
| RG-INC-12 |
decided_at, sent_at, first_submitted_at and late_by are stamped by the transition_to() override only : excluded from every ModelForm, read_only in every serializer, absent from every MCP writable list. Write-once is prevented at application level and detected through HistoricalRecords; QuerySet.update(), bulk_update() and raw SQL bypass save(). |
| RG-INC-14 | An incident cannot close while any obligation still has decision = undecided. The obligation register is a closure gate, not a side panel. |
| RG-INC-15 | Reclassifying an incident as a mere event is refused when any obligation already carries a sent_at. You cannot un-declare something you have already told a regulator about. |
| RG-INC-17 | An incident with is_exercise = True never instantiates obligations, and is exempt from the RG-INC-19 justification gate. |
| RG-INC-18 |
personal_data_involved = True forces gdpr_art33_authority to be instantiated at triage regardless of the plan's configured regimes, and in phase 2 creates the PersonalDataBreach record (saved, then transitioned to under_qualification). |
| RG-INC-19 | When triage produces zero 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. |
| 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. |
| RG-INC-26 | Recording an actual filing requires only incidents.notification.update. Only the transitions that declare an obligation extinguished require approve, so the operator on a 24-hour clock is never blocked waiting for an approver. |
| RG-INC-27 |
due_at is recomputed in save() from the anchor plus deadline_hours only while first_submitted_at is null, and is never editable directly. Obligations with no_fixed_deadline = True carry a null due_at, are never counted late, and are surfaced in a dedicated bucket rather than hidden. |
| 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 anchor correction can never silently un-breach a filed record. |
| RG-INC-29 |
content and sent_at are write-once once sent_at is set (prevention at application level, detection via HistoricalRecords). In phase 1 an amendment is a new obligation of the appropriate follow-up regime; in phase 2 it is an additional filing with is_correction = True and supersedes, on the same obligation, so the same-obligation relationship is never lost. |
| RG-INC-30 | Obligation terms generated from a template (regime, recipient_kind, obligation_reference, clock_anchor, deadline_hours, no_fixed_deadline, content_requirements, authority) are snapshotted at creation and are never rewritten by later template edits; the template FK is PROTECT. Generation only considers templates in a reportable() lifecycle state, never an is_active literal. |
| RG-INC-37 | Every report, KPI, calendar feed, kanban bucket and link picker filters through reportable() / linkable() / deletable_states(). No incident_notification state literal appears outside incidents/constants.py. |
| RG-INC-38 | Scope tenancy : the obligation is never independently scoped and inherits the incident's scope through scope_parent_lookup = "incident__scopes" on the web, API and MCP surfaces. |
| RG-INC-39 | Gated by incidents.notification.*. Phase 2 adds no new permission feature : PersonalDataBreach gates on the same one. |
| RG-INC-40 | The daily escalate_incident_deadlines command sweeps obligations with decision = required, a null sent_at and a due_at inside the alert window or already past. It takes --dry-run, excludes terminal steps via workflow_state__in, and iterates with a per-row save() - never QuerySet.update() - so HistoricalRecords captures every change. |
Base path /api/v1/incidents/, router registration notifications.
-
GET /api/v1/incidents/notifications/: list, filtered byIncidentNotificationFilter(incident_id,regime,recipient_kind,decision,status,overdue,due_before,no_fixed_deadline).overdueis a method filter derivingdue_at < now AND sent_at IS NULL AND step not terminal- there is no stored overdue flag to filter on. -
POST /api/v1/incidents/notifications/andPOST .../batch/(max 100 items, non-atomic, per-item{index, status, id, reference}). A manually added obligation is created indraftand moved on by a transition like any other row. GET/PUT/PATCH/DELETE /api/v1/incidents/notifications/<uuid>/-
GET/POST /api/v1/incidents/notifications/<uuid>/transition/:LifecycleAPIMixin, routed throughtransition_to(enforce_permission=True), so every gate above applies identically to an API caller. -
GET /api/v1/incidents/notifications/<uuid>/history/:core.history.build_timeline. -
GET /api/v1/incidents/notifications/<uuid>/proof/: a dedicated permission-checked and scope-checked detail action returning the proof bytes.proof_file_contentnever appears in a list or detail payload.
Viewset stack in the house order : BatchCreateMixin, ScopeFilterAPIMixin (with scope_parent_lookup = "incident__scopes"), LifecycleAPIMixin, HistoryAPIMixin, CreatedByMixin, viewsets.ModelViewSet. Permissions use ModulePermission plus the module's _IncidentViewSet base (permission_module = "incidents", custom_action_map = {"transition": "update"}), following the newest module precedent (trust_center/api/views.py _ManagedViewSet) rather than importing another app's ModulePermission subclass. IncidentNotificationSerializer / IncidentNotificationListSerializer, with read_only_fields covering id, reference, created_by, created_at, updated_at, version, recipient_key, decision, decided_by, decided_at, sent_at, sent_by, anchor_at, due_at, first_submitted_at and late_by. status is exposed as CharField(source="workflow_state", read_only=True), and is_overdue as a read-only derived boolean.
-
_register_crud(server, "incident_notification", IncidentNotification, "incidents.notification", scope_parent_lookup="incident__scopes", ...)generateslist_incident_notifications,get_incident_notification,create_incident_notification,batch_create_incident_notifications,update_incident_notification,delete_incident_notification,transition_incident_notification,incident_notification_allowed_transitions,get_incident_notification_history. Filters :incident_id,regime,recipient_kind,decision,status. -
list_overdue_incident_notifications(bespoke; requiresincidents.notification.read) returns obligation, incident, regime, recipient,due_at, hours overdue and owner, scope-filtered. This is the single highest-value read tool in the module for an external agent : it is the are we late question, answered in one call. -
record_notification_filing(bespoke, phase 2; requiresincidents.notification.update) creates the NotificationFiling and freezesfirst_submitted_at,late_byandwas_lateatomically, so an agent cannot record a transmission without freezing the lateness verdict that goes with it. -
decision,decided_by,decided_at,sent_at,anchor_at,due_at,first_submitted_atandlate_byare absent fromwritable_fields: the decision and the filing are transitions, never field writes.proof_file_contentis never readable or writable through MCP. - Every enum field carries an explicit
enumlist infield_overrides;contentandcontent_requirementsare declared with_html_field(); each recipient FK id carries a description naming its lookup tool.
mcp/tools.py HELP_TEXT gains IncidentNotification=INOT in the reference-prefix block, and assistant/catalog.py gains a read-only list_incident_notifications ToolSpec with detail_route="incidents:notification-detail".
| Codename | Description |
|---|---|
incidents.notification.read |
List and read obligations, deadlines and decisions |
incidents.notification.create |
Add an obligation by hand |
incidents.notification.update |
Edit an obligation, confirm it is required, draft it, record a filing, record an acknowledgement |
incidents.notification.approve |
Decide not to notify, archive, restore |
incidents.notification.delete |
Delete a manually added obligation still in draft or assessed
|
The same feature gates PersonalDataBreach and NotificationFiling in phase 2 : RG-INC-39 caps the module at exactly six features, and this is one of them.
-
Detail (
/incidents/notifications/<uuid>/) : a strict 2-column card layout, no nav-tabs. Left column, stacked cards :-
Obligation : regime,
obligation_reference, recipient, and thecontent_requirementschecklist rendered beside the drafting field, so the drafter reads the legal minimum content without leaving the page. -
Decision :
decision_rationale,decided_by,decided_at. Rendered as a prominent, full-width card wheneverdecision = not_required, because that is the sentence an inspector reads first. When the decision is stillundecidedthe card shows an explicit "no decision taken" state, never an empty block. -
Filing : channel, content,
sent_at, acknowledgement reference, proof download; in phase 2, the NotificationFiling history table withsupersedeschains. - Right column, sticky :
{% workflow_badge %}, the deadline with a live countdown, the anchor it derives from stated in words ("72 h from awareness, 14 Mar 09:12"), the "no statutory deadline" or "deadline pending" badge where applicable, the incident link, and the history trigger.
-
Obligation : regime,
-
On the incident detail page, obligations render as a Regulatory notifications card : regime, recipient,
due_at, live countdown, a muted badge forno_fixed_deadlinerows, a distinct badge fordeadline pendingrows, and a red overdue state. A row inassessedis visually the loudest thing in the card : an undecided obligation is the one thing on that page that must not read as calm. -
List (
/incidents/notifications/) : the house stack, with predefined filters for To decide, Overdue, Due in 24 h, Not required and No statutory deadline, andlist_rail_kpisshowing the undecided and overdue counts. -
Stepper :
{% include "includes/lifecycle_stepper.html" %}fed byLifecycleStepperMixin. Never a decision select, never plain buttons : the decision is the transition. This lifecycle has twoStepKind.ARCHIVEDsteps (not_required,archived), so the dagre renderer draws two detached exits; check both at desktop and mobile widths in light and dark mode. - Countdown colours use the semantic status palette only, never the navy identity colour, per the brand guidelines; the countdown must remain legible in both themes and must degrade to a plain absolute datetime when JavaScript is unavailable.
Every user-facing string is wrapped and given a French translation 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.
Enum labels, field verbose names and template strings that collide with an existing bare msgid use pgettext_lazy("incident", ...) in Python and {% trans "..." context "incident" %} in templates, with a matching msgctxt "incident" block in the .po :
| String | Existing bare entry | Action |
|---|---|---|
NotificationDecision.REQUIRED "Required" |
django.po -> "Requis" |
pgettext_lazy("incident", "Required") |
NotificationRegime.OTHER "Other" |
django.po (and three more) -> "Autre" |
pgettext_lazy("incident", "Other") |
NotificationChannel.EMAIL "Email" |
django.po -> "Email" |
pgettext_lazy("incident", "Email") |
| "Incident" (field label and card headings) | existing bare entry | pgettext_lazy("incident", "Incident") |
"Evidence" (proof_evidence label) |
django.po -> "Preuves" |
{% trans "Evidence" context "incident" %} |
| "Decision" (the decision card heading) | existing bare entry | {% trans "Decision" context "incident" %} |
NotificationChannel.PHONE reuses the existing bare "Phone" -> "Téléphone", which is correct as it stands. Every other label in this file ("Undecided", "Not required", "Portal", "Postal mail", "In person", "Public notice", "Generated", "Added manually", the five ClockAnchor labels, and all 22 regime and 12 recipient-kind labels) is a new, non-colliding bare msgid.
Step and transition labels must never use pgettext_lazy. lifecycle_to_json() stringifies each label with str(...) and lifecycle_from_json() re-wraps the stored string with bare gettext_lazy (core/lifecycle.py lifecycle_from_json()), so a msgctxt carried in code is lost after the post_migrate round-trip through LifecycleDefinition and the label resolves to whatever the bare msgid maps to. Three of this lifecycle's step labels collide, and all three are safe because the existing bare French is the right word here : "Draft" (django.po -> "Brouillon"), "Required" (django.po -> "Requis") and "Archived" (django.po -> "Archivé") are reused as they stand, as are the core "Archive" and "Restore" transition labels. Where an existing translation had been wrong for this context, the fix would be to rename the step, not to add a context : that is exactly why the assessed step is labelled "To decide" rather than "Assessed", and why the omission step is "Not required" rather than the already-taken "Not applicable".
After editing the .po, verify there is no duplicate msgid without a distinguishing msgctxt.
- GDPR Art. 33(1) : notification to the supervisory authority without undue delay and, where feasible, not later than 72 hours after becoming aware, unless the breach is unlikely to result in a risk. The omission path is this entity's
not_requiredstep. - GDPR Art. 33(2) : a processor notifies the controller without undue delay -
no_fixed_deadline = True, recipient is the controller. - GDPR Art. 33(3)(a)-(d) : the minimum content of a notification, rendered as
content_requirementsand structured on PersonalDataBreach in phase 2. - GDPR Art. 33(4) : information may be provided in phases - successive NotificationFiling rows on one obligation.
- GDPR Art. 33(5) and Art. 34(1) / 34(3) : the internal register and the communication to data subjects with its exemptions.
- NIS2 Art. 23(1), 23(3) and 23(4)(a)-(d) : recipients of the service, the significance test, and the 24 h / 72 h / on-request / one-month sequence. Art. 23(4)(d) is the reason
ClockAnchor.PREVIOUS_STAGEanddepends_onexist. - DORA Art. 19 : initial, intermediate and final major ICT incident reports on the same mechanism.
- ISO/IEC 27001:2022 A.5.26 (response to information security incidents), clause 10.2 f) (retained documented information)
-
Incident : the parent,
awareness_atas the legal anchor, and the triage transition that generates obligations - NotificationFiling : the append-only log of actual transmissions
- ReportingAuthority, ReportingObligationTemplate, PersonalDataBreach : the phase-2 catalogue and GDPR qualification record
-
IncidentResponsePlan :
applicable_regimesdrives phase-1 generation - README.md : module business rules, permissions, notifications, environment variables
-
governance/workflow.md and governance/lifecycle.md : the lifecycle framework and
LifecycleEvent
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)