Skip to content

Specs M3 Compliance Finding

Claude edited this page Aug 28, 2026 · 1 revision

Finding

compliance.models.finding.Finding

The organisation's single nonconformity register. A Finding records one departure from a requirement, a control or the organisation's own procedure, whatever surfaced it : an assessment, a security incident, a management review, routine monitoring or a complaint. It is the ISO/IEC 27001:2022 clause 10.1 (nonconformity and corrective action) record, and its effectiveness fields are the clause 10.2 d) and f) record.

Historically this entity was an audit finding : it required an assessment and existed only inside one. The security incident module (m6) forced the question of where an incident-born nonconformity lives, and the answer taken was to generalise this entity rather than to create a second register. An auditor asking "show me your nonconformities" must receive one list, not two to reconcile.

Why one register and not two

The alternative considered and rejected was an IncidentNonconformity in m6. It would have left Finding untouched and cost nothing in blast radius, but:

  • clause 10.2 would be answered in two places, and the two would drift;
  • the worst-finding-wins compliance scoring in ComplianceAssessment.apply_findings_to_results() and the management-review nonconformity section would both ignore incident-born nonconformities unless every surface were rewired twice;
  • an organisational weakness such as the joiner-mover-leaver procedure is not followed has no CVE and no affected asset, so it cannot be parked in risks.Vulnerability (see the risks module) either, and the fallback would have been a discarded event with free-text notes.

The cost of the decision is real and is stated here rather than buried : it changes an existing module and it breaks an existing MCP permission contract. See Migration and breaking changes.

Fields

Field Type Constraints Description
id UUID PK, auto-generated Unique identifier
reference string auto-generated, unique, per-type prefix NCMAJ-1, NCMIN-1, OBS-1, OA-1, STR-1. Generated by _generate_reference_for_type() from FINDING_REFERENCE_PREFIXES, not by ReferenceGeneratorMixin, because the prefix depends on finding_type and is longer than four characters.
assessment relation FK -> ComplianceAssessment, optional, SET_NULL Changed. Was required with CASCADE. Now nullable, so a nonconformity can exist without a fabricated audit, and SET_NULL, so deleting an audit no longer destroys the nonconformities it raised. Reverse accessor findings.
source enum required, default audit New. FindingSource : what surfaced the nonconformity. The default keeps every pre-existing row semantically unchanged.
incident relation FK -> Incident, optional, SET_NULL New. Set when source = incident. Reverse accessor findings. Note that findings now denotes different things on User, ComplianceAssessment, Requirement and Incident; that is legal but worth knowing when reading code.
finding_type enum required FindingType : major_nc, minor_nc, observation, improvement, strength. Drives the reference prefix and the severity ordering.
description text required The nonconformity itself : what was observed, against what requirement
recommendation text optional, blank default Recommended treatment. Labelled Auditor recommendation; the label is now source-neutral in the UI.
evidence text optional, blank default Evidence presented at the time of the observation
assessor relation FK -> AUTH_USER_MODEL, optional, PROTECT Changed. Was required. Now null=True, blank=True, with clean() requiring it when source == audit. The column is unchanged; only verbose_name moves from Assessor to Raised by, because the field names whoever raised the nonconformity and that is no longer always an auditor. A PostIncidentReview stamps it with its facilitator when it raises one.
requirements relation M2M -> Requirement, optional Requirements the nonconformity bears on. Feeds apply_findings_to_results().
effectiveness_reviewed_at datetime New, optional When the corrective action's effectiveness was reviewed. Clause 10.2 d).
effectiveness_reviewed_by relation New, FK -> AUTH_USER_MODEL, optional, SET_NULL Who reviewed it
effectiveness_verdict enum New, optional, blank default EffectivenessVerdict : effective, partially_effective, not_effective. Clause 10.2 f).

Inherited from BaseModel : workflow_state (default 4-state lifecycle, unchanged), version, created_by, created_at, updated_at, tags, and HistoricalRecords.

FindingSource

Value Label
audit Audit
incident Security incident
management_review Management review
monitoring Monitoring
complaint Complaint

Audit and Monitoring and Complaint already exist as bare msgids in the catalogue, so these labels are declared with pgettext_lazy("finding source", ...) and a matching msgctxt.

EffectivenessVerdict

Value Label
effective Effective
partially_effective Partially effective
not_effective Not effective

Why effectiveness is a field here and not a state elsewhere

ComplianceActionPlan runs a 7-state Kanban lifecycle that ends at Done. Done proves an action was implemented. It says nothing about whether the nonconformity stopped recurring, which is exactly what clause 10.2 d) review the effectiveness of any corrective action taken asks. Adding an eighth state to the action plan would answer it for action plans only, and a nonconformity can be closed by several action plans or by none. The verdict therefore belongs to the nonconformity.

Business rules

ID Rule
RG-FND-01 assessment is optional. A Finding with source = audit and no assessment is a data error, not a supported state : clean() requires an assessment when source == audit.
RG-FND-02 assessor is required when source == audit and optional otherwise, enforced in clean(). The column stays non-null-constrained at database level only in the sense that existing rows are unaffected; the field itself becomes null=True.
RG-FND-03 Deleting a ComplianceAssessment no longer deletes its findings : the FK is SET_NULL. A nonconformity survives the audit that raised it, which is the point of a register.
RG-FND-04 ComplianceAssessment.apply_findings_to_results() and the assessment's own scoring iterate the reverse accessor self.findings, which by construction never yields a null-assessment row. Incident-born nonconformities therefore never leak into audit-scoped scoring, with no filter to remember and no null guard to write.
RG-FND-05 The management-review nonconformity section is deliberately source-agnostic : it lists every nonconformity regardless of origin, which is what ISO 27001 clause 9.3.2 c) asks for. This is the one place the two populations are meant to meet.
RG-FND-06 effectiveness_verdict may only be set once the nonconformity has at least one linked ComplianceActionPlan in a reportable state, or an explicit justification for why none was needed. A verdict about the effectiveness of nothing is not a record.

Migration and breaking changes

This is the part to read before merging.

Schema. One migration on compliance : alter assessment (CASCADE -> SET_NULL, null=True), alter assessor (null=True, verbose_name), add source, incident, and the three effectiveness fields. source defaults to audit, so every existing row keeps its exact current meaning and no data migration is needed. The incident FK lands with the m6 app itself, since compliance cannot import incidents before it exists.

A new permission feature. There is no compliance.finding feature today : findings are gated by compliance.assessment.*. Promoting the register to a standalone entity introduces compliance.finding with the usual action set, which requires its own accounts data migration. Without it the feature exists in PERMISSION_REGISTRY, tests pass because the test fixtures seed from the registry, and on a real database it is granted to nobody.

A breaking MCP contract change. list_findings, get_finding, create_finding, update_finding and delete_finding already exist and are gated compliance.assessment.read / .create / .update / .delete. They are re-gated to compliance.finding.*. Any external MCP client holding assessment permissions but not finding permissions loses access at the moment of upgrade. This must be logged as a ### Changed entry in CHANGELOG.md and reflected in docs/mcp-server.md.

New web surfaces. Every finding route in compliance/urls.py is currently nested under an assessment (assessments/<uuid:assessment_pk>/findings/...). A nonconformity with no assessment is unreachable through them, so the register needs standalone list, detail, create, update and delete routes alongside the existing nested ones. The nested routes stay : creating a finding from inside an audit is still the common path.

Endpoints

REST

  • GET /api/v1/compliance/findings/ : list, filters assessment, source, incident, finding_type, effectiveness_verdict, workflow_state
  • POST /api/v1/compliance/findings/ : create
  • GET|PATCH|DELETE /api/v1/compliance/findings/{id}/ : retrieve, update, delete
  • GET /api/v1/compliance/findings/{id}/history/ : audit trail

MCP

list_findings, get_finding, create_finding, update_finding, delete_finding, batch_create_findings. Writable fields gain source, incident_id, effectiveness_reviewed_at, effectiveness_reviewed_by_id and effectiveness_verdict; assessment_id becomes optional. Read-only *_name companions follow the house convention : assessment_name, incident_reference, assessor_name, effectiveness_reviewed_by_name.

Permissions

Codename Grants
compliance.finding.read View the nonconformity register and any finding
compliance.finding.create Raise a nonconformity
compliance.finding.update Edit a nonconformity, record an effectiveness verdict
compliance.finding.delete Delete a nonconformity while its lifecycle step allows it

The six system roles receive these through the existing suffix lambdas in accounts/constants.py, unchanged.

References

Clone this wiki locally