-
Notifications
You must be signed in to change notification settings - Fork 2
Specs M6 Incidents Notification Filing
incidents.models.filing.NotificationFiling
The filing log : one append-only record per actual transmission made against an IncidentNotification obligation. Who sent what, to whom, when, through which channel, with the authority's case number and the verbatim content that left the organisation.
This is the evidence handed to an auditor or an inspector who asks prove you filed the 72-hour notification. The obligation says what was owed and when; the filing says what was actually done, and it is the only record that can answer the question with a document rather than a status.
File : incidents/models/filing.py
Phase 2. Not a BaseModel and not a ScopedModel : a plain models.Model with ReferenceGeneratorMixin, its own UUID primary key, sequential reference (prefix NFIL, e.g. NFIL-12), explicitly declared row timestamps, a version counter and a django-simple-history audit trail.
No lifecycle. A transmission has no states. It happened, at a time, through a channel, with a content. Giving it a workflow would put a governance process around a fact, and would make the log filterable by a step that carries no meaning. The entity has no workflow_state column, BaseModel._ensure_initial_step() never runs against it, and it is deliberately invisible to reportable(), linkable() and deletable_states(). It is read through its parent obligation, always. What the filing does carry is outcome, which records the recipient's response - a fact about the world, not a state of our process.
A reference prefix, unlike the chronology. IncidentTimelineEntry deliberately has none, because ReferenceGeneratorMixin._generate_next_reference() scans every existing reference sharing the prefix on each insert and a live incident produces hundreds of narrative entries. Filings are the opposite shape : a busy obligation has two or three, a heavily contested one perhaps a dozen, and each one is cited - in correspondence with the authority, in the incident register export, in the Art. 33(5) extract, in a later legal exchange. "The 72-hour notification was filed as NFIL-12 on 14 March at 08:41 and supplemented by NFIL-19 on 21 March" is a sentence someone has to be able to write. The scan cost is paid a handful of times per incident and buys a citable identity, which is the trade the mixin exists for.
Never rewritten. A filing that can be edited after the fact is not evidence of what was sent. content, subject, submitted_at, channel, was_late, is_correction and supersedes refuse any update once the row exists. A correction to what the organisation told a regulator is a new filing, never an edit of the old one.
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
UUID | PK, default=uuid.uuid4, editable=False
|
Unique identifier |
reference |
string | auto-generated NFIL-N, unique, max 50 |
Citable filing reference |
notification |
relation | FK -> IncidentNotification, required, CASCADE, related_name="filings"
|
The obligation this transmission discharges, in whole or in part. CASCADE is safe : the obligation itself is PROTECTed against deletion from required onward, and an obligation that can still be deleted has no filings by construction. |
submitted_at |
datetime | required, indexed, immutable | When the filing actually left the organisation. Not the time the row was typed : a portal submission recorded two hours later carries the submission time, and created_at reveals the delay. |
channel |
enum | required, default portal, max 20 |
NotificationChannel, declared once in incidents/constants.py and shared with the parent obligation. |
recipient_name |
string | optional, max 255, blank default | The named desk, mailbox or person who received it, when it is finer-grained than the obligation's recipient (e.g. "CNIL - service des violations de données"). |
external_reference |
string | optional, max 200, blank default | The authority's case, ticket or receipt number. Write-once completion field : see Append-only : what is actually guaranteed. |
subject |
string | optional, max 500, blank default, immutable | Subject line of the filing |
content |
text | optional, HTML, blank default, immutable | Verbatim content of what was sent. Never edited : a correction is a new row. This is the field an inspector reads. |
outcome |
enum | required, default sent, max 25 |
FilingOutcome : the recipient's response. Write-once completion field.
|
acknowledged_at |
datetime | optional | When the recipient acknowledged receipt of this filing. Write-once completion field. |
is_correction |
boolean | required, default False, immutable
|
Marks a corrective or supplementary filing (GDPR Art. 33(4) phased provision, or a response to a NIS2 information request). The first filing on an obligation is never a correction. |
was_late |
boolean | required, default False, write-once, computed at insert
|
Frozen lateness verdict for this filing, computed once from the obligation's due_at at the moment of insert and never recomputed. False when the obligation carries no due_at. |
proof_file_content |
binary | optional, editable=False
|
The filed document, the generated PDF or the portal receipt, stored as bytes following Contract.file_content, Certificate.file_content and TrustCenterDocument.file_content. Excluded from list serializers, from MCP list_fields and from HistoricalRecords. Capped by INCIDENT_NOTIFICATION_MAX_PROOF_BYTES. |
proof_filename |
string | optional, max 255, blank default | Original filename of the proof |
version |
int |
PositiveIntegerField, default 1
|
Row version counter, mirroring the SupplierSubprocessor precedent for non-BaseModel audit rows. A value other than 1 means a completion field was filled in after the insert, which is legitimate but visible. |
created_at / updated_at
|
datetime |
auto_now_add / auto_now
|
Row timestamps. Declared explicitly because BaseModel is not inherited. created_at versus submitted_at is the recording delay. |
history |
HistoricalRecords(excluded_fields=["proof_file_content"]) |
Tamper detection. Any post-hoc write to a filing record is visible, including the permitted completion writes. |
| Name | Type | Target | Reverse accessor | Description |
|---|---|---|---|---|
notification |
FK, CASCADE, required |
IncidentNotification | filings |
The obligation being discharged |
submitted_by |
FK -> User, SET_NULL, optional |
User | incident_filings |
Who transmitted it. SET_NULL rather than PROTECT : the filing's evidential weight rests on its content and its receipt, not on the account still existing, and HistoricalRecords keeps the name. |
supersedes |
FK -> self, SET_NULL, optional, immutable
|
NotificationFiling | superseded_by |
The earlier filing on the same obligation that this one replaces. Null on a supplementary filing that adds information without retracting anything. |
-
ordering = ["-submitted_at"]: the most recent transmission first, which is what the detail page and the API caller both want. The obligation's narrative order is the reverse, and the UI renders it ascending. -
CheckConstraint filing_supersedes_implies_correction:Q(supersedes__isnull=True) | Q(is_correction=True). A filing that replaces another is by definition a correction. -
clean()refuses asupersedespointing at a filing on a different obligation. A supersession chain that crosses obligations would break the one guarantee this entity provides. -
clean()refusessubmitted_atin the future.
Reproduced verbatim from incidents/constants.py (DB value = Label).
| Value | Label |
|---|---|
sent |
Sent |
acknowledged |
Acknowledged |
rejected |
Rejected |
information_requested |
Information requested |
superseded |
Superseded |
information_requested is the value that matters operationally : a NIS2 competent authority asking for more, or a supervisory authority reverting on a 72-hour filing, drives the parent obligation's acknowledged -> drafted transition and produces the next filing on the same row.
Declared once in incidents/constants.py and shared with IncidentNotification : portal, email, postal, phone, api, in_person, public_notice. The full table is in that file.
The module states this plainly rather than claiming an immutability the schema does not provide.
Prevention is at application level. save() inspects self._state.adding. On an insert it proceeds. On any subsequent save it compares the incoming values against the stored row and raises core.lifecycle.LifecycleProtectedError (the house exception, the same one BaseModel.delete() raises when a lifecycle state forbids deletion) unless the only changed fields are the three completion fields below. delete() raises LifecycleProtectedError unconditionally.
The narrow completion exception. Exactly three fields may be written after the insert, exactly once each, and only from their insert value to a set value - never from one set value to another :
| Field | Why it cannot be known at insert |
|---|---|
outcome |
The recipient's response arrives after the transmission, sometimes days later |
acknowledged_at |
Same |
external_reference |
A portal returns a case number immediately, an email filing does not |
Every other field is immutable from the instant the row exists. This is the deliberate boundary : what we said is frozen, what they answered is completable. The exception is implemented in one place (NotificationFiling.record_outcome()), the save() guard refuses any other post-insert write including a second write to a completion field, and every completion write is historised, so a filing whose historical trail shows more than two rows has been touched more than the design allows.
One consequence is worth stating because it is easy to get wrong : FilingOutcome.SUPERSEDED is not stamped on an old filing when a new one replaces it. Doing so would be a post-insert write to a field outside the completion set. Supersession is derived from filing.superseded_by.exists(), which is what the UI and the exports read. The superseded value exists only for a historical import that records a filing already known to have been replaced.
What bypasses the guard :
-
QuerySet.update()andQuerySet.bulk_update()issue SQL without callingsave(). -
QuerySet.delete()and cascade deletion do not callModel.delete(). A cascade from the obligation therefore removes filings without the guard firing; in practice the obligation is undeletable fromrequiredonward and cannot have filings before that, but the mechanism is stated here so nobody discovers it during an audit. - Raw SQL, a
manage.py shellsession and direct database access bypass Python entirely.
Detection is via HistoricalRecords. Every ORM-level write that does go through save() leaves a historical row with the acting user. The honest claim to make to an auditor is therefore : tampering with the filing log is prevented on every supported path and detectable on the rest, not the filing log is immutable. Real database-level immutability would need PostgreSQL rules or triggers, which core.settings_test (SQLite in memory, migrations disabled) cannot exercise; that divergence is not taken in this module, and if it is ever taken it must be taken deliberately and documented here.
This is the entity's reason to exist, and it is worth spelling out against the two designs it replaces.
GDPR Art. 33(4) says that where, and in so far as, it is not possible to provide the information at the same time, the information may be provided in phases without undue further delay. A 72-hour notification is routinely filed with an approximate number of data subjects and a provisional description, and completed a week later. NIS2 works the same way by construction : Art. 23(4)(c) is an intermediate report on request of the competent authority, and an authority can ask more than once.
Two obvious models both fail :
- Editing the original. The record then says the organisation filed, on day one, a document it actually assembled on day eight. That is not what happened, and the difference is exactly what an inspection looks for. It also destroys the evidence of the first filing, which is the one the 72-hour clock is measured against.
-
A second obligation row. The register then shows two GDPR Art. 33(1) obligations on one incident, each with its own clock, and the answer to when did you notify becomes ambiguous. Worse, the second row's
due_atwould be recomputed from the anchor and would look on time when the duty was discharged late.
The filing log resolves both. The obligation stays one row : one duty, one clock, one decision, one lateness verdict. Each transmission is a filing :
-
NFIL-12,submitted_atday 1,is_correction = False,supersedes = null,outcome = sent. This filing stamps the obligation'sfirst_submitted_at,late_byandsent_at. -
NFIL-19,submitted_atday 8,is_correction = True,supersedes = null: a supplementary Art. 33(4) filing that adds the confirmed subject count without retracting anything. -
NFIL-24,submitted_atday 21,is_correction = True,supersedes = NFIL-12: a correction that replaces a statement made in the first filing.NFIL-12remains, verbatim and unmodified, and renders as superseded with a link toNFIL-24.
The obligation's lateness verdict never moves : it was set by NFIL-12 and it is a fact about the 72-hour duty, not about the last thing anyone sent. A chain may be arbitrarily long, and supersedes is followed to its end when the register export quotes a statement, so a generated document never quotes text the organisation has since retracted while still showing, in an appendix, that the retraction happened.
An authority information request runs the same way : the obligation moves acknowledged -> drafted with a mandatory comment, the response is a new filing with is_correction = True, and the request itself is narrated in the incident's chronology with entry_type = external_input.
Creating the first filing on an obligation is a single atomic act performed through the obligation's transition_to() override (RG-INC-08), never by writing the filing alone. Inside one transaction.atomic() block it :
- inserts the filing, computing
was_latefromnotification.due_atagainstsubmitted_at; - stamps the obligation's
first_submitted_at = submitted_at,sent_at,sent_byandchannel; - computes the obligation's
late_byas the positive part offirst_submitted_at - due_at, or null when the obligation carries nodue_at; - moves the obligation to
sent; - recomputes
anchor_atanddue_aton every obligation innotification.dependentswhoseclock_anchorisprevious_stage- this is the moment the NIS2 Art. 23(4)(d) one-month final-report clock actually starts; - appends a lifecycle entry to the incident's chronology.
After step 2 the obligation's save() stops recomputing anchor_at and due_at for good (RG-INC-28). A later correction to Incident.awareness_at - which stays editable, because facts change - therefore cannot move a filed obligation's deadline and cannot silently un-breach it. The full argument is in The clock model.
Subsequent filings insert normally and change none of the frozen values.
On MCP this is the bespoke record_notification_filing tool, which exists precisely so an agent cannot record a transmission without freezing the lateness verdict that goes with it. On the web it is the Record filing form on the obligation's stepper.
RG-INC-38. NotificationFiling is not a ScopedModel and never carries its own scopes. It is a grandchild of the incident and chains its parent's lookup : scope_parent_lookup = "notification__incident__scopes".
Scope inheritance for non-ScopedModel children is not currently enforced on three surfaces, and phase 1 extends all three; this entity depends on that work being in place before it ships. The change is core work, logged under a ### Security entry in CHANGELOG.md :
-
mcp/tools.py_filter_by_scopes()handlescontext.Scopeand a directscopesM2M, then returns the queryset unfiltered. Aparent_lookupparameter is added and threaded through_register_crud/_list_handler/_get_handler. Without it,list_notification_filingsreturns every filing on the instance - verbatim regulatory content included - to any holder ofincidents.notification.read. -
core/workflow_views.pyguards withhasattr(obj, "scopes"). This entity exposes no transition endpoint, but the obligation it hangs off does, and the fix is one guard. -
core/history_views.pycarries the samehasattr(obj, "scopes")guard, so the full history of a filing is otherwise readable cross-scope.
| ID | Rule |
|---|---|
| RG-INC-26 | Recording a 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-28 | Lateness is frozen once : IncidentNotification.first_submitted_at, IncidentNotification.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 | A notification's content, channel and sent_at are write-once once sent_at is set (prevention at application level, detection via HistoricalRecords). An amendment is an additional filing 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-37 | Every report, KPI, calendar feed and export filters through the governance helpers on the parent obligation. No state literal appears outside incidents/constants.py. |
| RG-INC-38 | Scope tenancy : filings are never independently scoped and chain the incident's scope through scope_parent_lookup = "notification__incident__scopes" on the web, API and MCP surfaces. |
| RG-INC-39 | Gated by incidents.notification.*. There is no incidents.filing feature and there will never be one : the module is capped at exactly six permission features. |
Base path /api/v1/incidents/, router registration notification-filings. The viewset is create, list, retrieve and one narrow completion PATCH : http_method_names is restricted so no PUT or DELETE route is generated at all, matching the append-only rule at the routing layer rather than only in the serializer. A single PATCH route is exposed and accepts only the three completion fields, rejecting every other key with a 400 rather than silently ignoring it.
-
GET /api/v1/incidents/notification-filings/: list, filtersnotification_id,incident_id,channel,outcome,is_correction,was_late,submitted_after,submitted_before. -
POST /api/v1/incidents/notification-filings/(+POST .../batch/viaBatchCreateMixin, max 100 items, non-atomic, per-item{index, status, id, reference}). Creating the first filing on an obligation performs the whole freeze described above, inside one transaction. GET /api/v1/incidents/notification-filings/<uuid>/-
PATCH /api/v1/incidents/notification-filings/<uuid>/: completion only (outcome,acknowledged_at,external_reference). -
GET /api/v1/incidents/notification-filings/<uuid>/history/viaHistoryAPIMixin. -
GET /api/v1/incidents/notification-filings/<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 : BatchCreateMixin, ScopeFilterAPIMixin (with scope_parent_lookup = "notification__incident__scopes"), HistoryAPIMixin, CreatedByMixin, viewsets.ModelViewSet. LifecycleAPIMixin is not mixed in : the entity runs no lifecycle, so no transition/ route exists. Permissions use ModulePermission with permission_module = "incidents" and permission_feature = "notification", following the newest module precedent (trust_center/api/views.py _ManagedViewSet). reference, was_late, created_at, updated_at, version and submitted_by are read-only; submitted_by is stamped from the request user.
-
record_notification_filing(bespoke; requiresincidents.notification.update) creates the filing and, when it is the first on its obligation, freezesfirst_submitted_at,late_byandwas_lateand starts any dependent clock, all atomically. -
list_notification_filings(bespoke; requiresincidents.notification.read) reads the log, filtersnotification_id,incident_id,outcome,is_correction,was_late. Scope-filtered throughnotification__incident__scopes. -
get_notification_filing_historyvia the standard history handler.
There is no update tool and no delete tool. Completion is done through record_filing_outcome (requires incidents.notification.update), which sets outcome, acknowledged_at and external_reference once and refuses everything else - an agent must not be able to rewrite what an organisation told a regulator. content is declared with _html_field(), channel and outcome carry explicit enum lists in field_overrides, and proof_file_content is never readable or writable through MCP.
mcp/tools.py HELP_TEXT gains NotificationFiling=NFIL in the reference-prefix block.
Gated entirely by the parent obligation's codenames :
| Codename | Description |
|---|---|
incidents.notification.read |
Read the filing log and download a proof |
incidents.notification.update |
Record a filing and complete its outcome |
create, delete and approve have no meaning here : no route consumes them. Recording a filing is deliberately an update on the obligation rather than a create on a separate feature, because a filing is not an independent object - it is the discharge of a duty that already exists, and the person who can drive the obligation is the person who can file it (RG-INC-26).
Rendered as the Filing history table inside the Filing card on the IncidentNotification detail page. There is no standalone list page and no standalone detail page : a filing is meaningless outside its obligation.
- Rows ordered by
submitted_atascending, so the card reads as the exchange unfolded, with the reference, the channel icon (Bootstrap Icons only),submitted_at,submitted_by,external_referenceand the outcome badge. - A
was_late = Truerow carries a semantic danger badge stating by how much, computed from the obligation's frozenlate_byon the first filing. The badge uses the status palette, never the navy identity colour. - Superseded rows render struck-through with a link to the filing that replaced them; the replacing row renders with a back-link. Supersession is read from
superseded_by, never from a storedoutcome. -
is_correctionrows carry a distinct marker so a reader can see at a glance which parts of the exchange were phased provision (Art. 33(4)) rather than the original filing. -
contentopens in a Bootstrap collapse, rendered verbatim, with a copy affordance : this is the text someone will need to quote. - A Record filing form sits at the foot of the card and posts over HTMX into the
#notification-filingspartial, pre-fillingchannelandrecipient_namefrom the obligation andsubmitted_atfrom now. It offers no edit and no delete affordance anywhere; completing an outcome is a separate, narrow inline control on the row itself. - The proof download is a permission-checked and scope-checked action, never a raw media URL.
- The card must render correctly in light and dark mode and at mobile widths; the verbatim content collapse and the filing table are checked at small widths in particular, since regulatory content is long and must scroll inside its own container rather than widening the page.
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.
Two FilingOutcome labels already exist as bare msgids with the correct French and are reused as they stand, adding no new entry : "Rejected" (django.po -> "Rejeté") and "Superseded" (django.po -> "Remplacé"). "Sent", "Acknowledged" and "Information requested" are new, non-colliding bare msgids, as are the field labels "Filing", "Filing history", "Correction", "Supersedes" and "Proof".
NotificationChannel is declared once in incidents/constants.py and its colliding member is handled there : EMAIL uses pgettext_lazy("incident", "Email") because the bare "Email" entry (django.po) is shared with several hundred unrelated uses and the notification channel must be retranslatable ("Courriel") without touching them. PHONE reuses the existing bare "Phone" -> "Téléphone", which is correct as it stands. See IncidentNotification for the full table.
The lifecycle_from_json trap does not apply to this entity : it re-wraps stored step labels with bare gettext_lazy after the post_migrate round-trip through LifecycleDefinition (core/lifecycle.py lifecycle_from_json()), so a step label's msgctxt is lost. This entity declares no lifecycle and no steps, so its labels are plain field choices that never make that round trip. The trap does apply to its parent obligation, whose step labels include the colliding "Draft", "Required" and "Archived" : see that file.
After editing the .po, verify there is no duplicate msgid without a distinguishing msgctxt.
- GDPR Art. 33(4) : where, and in so far as, it is not possible to provide the information at the same time, the information may be provided in phases without undue further delay. The reason this entity exists rather than an editable content field.
- GDPR Art. 33(1) and Art. 33(5) : the 72-hour duty the first filing is measured against, and the internal register a filing log is the evidence for.
- GDPR Art. 34(1) : communication to data subjects, which is frequently phased in the same way.
- NIS2 Art. 23(4)(b), (c) and (d) : the incident notification, the intermediate report on request of the competent authority, and the final report whose one-month clock starts at the first filing of the notification.
- DORA Art. 19 : initial, intermediate and final major ICT incident reports, which are three obligations, each with its own filings.
- ISO/IEC 27001:2022 clause 10.2 f) : retained documented information on the results of the action taken.
- IncidentNotification : the parent obligation, the clock model, and the transition that creates the first filing
-
Incident :
awareness_atas the legal anchor, and why correcting it after a filing changes nothing - IncidentTimelineEntry : the same prevention-plus-detection framing, and why it carries no reference prefix while this entity does
-
IncidentEvidence : when a receipt is itself registered as A.5.28 evidence, through
IncidentNotification.proof_evidence - ReportingAuthority : the portal, mailbox and procedure a filing is transmitted through
- README.md : module business rules, permission codenames, scope inheritance and the phase plan
-
governance/history.md :
HistoricalRecordsand the merged history timeline
Built from docs/ at v0.36.0. Edits made here are overwritten by the next release : open a pull request against the source instead.
- Administration
- Ask Cairn
- Assets and suppliers
- Compliance
- The dashboard
- Finding your way
- Getting started
- Incidents
- How records move
- Organisational context
- Reports and management review
- Risks
- Trust Center
- Architecture
- Configuration
- Contributing
- The documentation system
- Installation
- Internationalisation
- Operations
- Release process
- Security
- Testing
- Adding an assistant provider
- Adding a dashboard widget
- Adding a domain entity
- Declaring a lifecycle
- Adding an MCP tool
- Adding a REST endpoint
- Adding a report
- Interface conventions
- Dashboard widgets
- Lifecycles
- MCP tools
- MCP tool parameters : Assets
- MCP tool parameters : Compliance
- MCP tool parameters : Governance and context
- MCP tool parameters : General
- MCP tool parameters : Incidents
- MCP tool parameters : Reports and management review
- MCP tool parameters : Risks
- MCP tool parameters : System and administration
- MCP tool parameters : Trust Center
- Management commands
- Models
- Permissions
- REST endpoints
- Environment variables
- MCP server
- REST API
- Assistant module (Ask Cairn)
- Module 0: User Management and Access Control
- Module 1: Context and Organization
- Module 2: Asset Management
- Module 3: Compliance
- Module 4: Risk Management
- Module 4 bis - EBIOS Risk Manager
- Module 5 : Trust Center
- Module 6 : Security Incident Management
- Management review : ISO 27001:2022 compliance (clause 9.3)