Skip to content

The Audit trail

ai_disclosure_audit keeps an append-only log of AI disclosure decisions: what was saved, what an editor decided about a suggestion, and every version a grade or profile went through. It exists because a historical revision of an entity does not, by itself, tell the truth about what it meant at the time.

The problem it solves

An ai_disclosure field item stores a grade reference, not the grade's own text. An old node revision still just says "grade": "ai_translated". What that meant (the sentence shown to the reader, whether a label was required, the severity, the icon) is read from the ai_disclosure_grade config entity at the moment someone looks, not at the moment the revision was saved.

That is fine until the grade changes. Editing ai_translated's disclosure sentence rewrites, retroactively, what every past revision that used it is taken to have said. Deleting the grade is worse: the resolver falls back to whatever else applies (a different grade, or none) and a revision that disclosed one thing now reads as having disclosed another, or nothing at all. Neither is a defensible compliance record.

This module keeps a second, separate trail that does not have this problem, because it never re-reads the config: it copies what the config said, once, into the row itself.

The two tables

ai_disclosure_audit

One row per decision about content: a save of an ai_disclosure field item, or an editor accepting or dismissing a suggestion.

Column What it holds
event_type save, suggestion_accepted or suggestion_dismissed.
entity_type_id, entity_id, entity_revision_id, langcode What this row is about. entity_revision_id is NULL for a non-revisionable entity type.
field_name, delta Which field item, for a save row. NULL/0 for a suggestion row, which is not about one field.
mode, grade, profile, human_review The literal values stored on the field item at save time: what an editor actually set, before inheritance or a profile is resolved. NULL on all four when the field itself is empty, which can still produce a row (see effective_grade below).
effective_grade The grade machine name the reader actually saw: the resolver's answer, after mode/profile/inheritance. The resolver looks at a single ai_disclosure field per entity, so this is set only on the row for the field it resolved. NULL on the row for any other ai_disclosure field on the same entity, and also NULL when there was no effective disclosure at all, itself a decision worth keeping, not a gap. An empty field left on the field instance's default profile still resolves to a real disclosure, so it gets a row too, with mode/grade/profile/human_review NULL and this column set. An empty field that is not the resolved one, or that resolves to nothing, produces no row at all.
grade_snapshot A JSON copy of what the effective grade said at save time: label, description, disclosure sentence, whether a label was required, severity, EU icon, IPTC digital source type, visible parts. Frozen. Set under the same condition as effective_grade, and NULL wherever that one is.
rationale The optional "Why this disclosure" note an editor left on the widget. Only ever set on a save row. NULL for a programmatic save, and always NULL on a suggestion row, because Accept and Dismiss do not go through the widget.
suggestion_id, suggestion_source Which suggestion a suggestion_accepted/suggestion_dismissed row is about, and which module recorded it.
suggestion_note The note frozen from the module that recorded the suggestion a suggestion_accepted/suggestion_dismissed row is about (SuggestionStatusChangeEvent::getNote()). Distinct from rationale: this is the recording module's own note, not an editor's. Kept here because the source row in ai_disclosure_suggestion is deleted along with the entity. Held at the same width as the column it copies from, so a long note is kept whole rather than truncated. NULL on a save row, or when the suggestion carried no note.
suggestion_previous_status The status the decision moved away from, which is what tells a dismissal of a pending suggestion apart from a dismissal of one that had already been accepted. NULL on a save row, and NULL on any row written before the column existed: that state was never recorded, and it is not guessed after the fact.
uid, created Who acted, and when.

ai_disclosure_audit_config

One row per version a grade or profile went through: every save, delete or rename.

Column What it holds
config_name The full configuration name, e.g. ai_disclosure.ai_disclosure_grade.ai_deepfake.
config_type grade or profile.
config_id The machine name.
operation save, delete or rename.
data A JSON snapshot of the config's state after the operation. NULL on delete: there is nothing left to snapshot, and the deletion itself is what the row means.
uid, created Who made the change, and when.

Any config save counts, including the ones nobody typed: installing a module that ships grades or profiles writes a save row for each of them, and so does a configuration import. That is the first version of each rule, recorded where later rows can be read against it.

Append-only, on purpose

AuditLogger, the single service that writes either table, only ever calls insert(). It never updates or deletes a row, and a saved entity being deleted does not take its audit rows with it: they are the record that the decision was once made, which deleting the entity does not undo.

The rationale is optional and editorial only

The widget gains an optional "Why this disclosure" textarea, inserted inside it between the description and status fields. It is never a property of the field item and is never part of what $entity->save() writes to the field's own tables: it exists only to reach this audit trail, as the editor's own explanation for the decision. Because of that, a save made from code (the recorder API, a migration, cron) carries no rationale: nothing filled the textarea in, so there is nothing to attest.

Subscribing to a suggestion's decision

Another module that wants to react to a suggestion being accepted or dismissed does not need to touch this one. The event this submodule listens to is public API on the parent module:

use Drupal\ai_disclosure\Event\AiDisclosureEvents;
use Drupal\ai_disclosure\Event\SuggestionStatusChangeEvent;

$event_dispatcher->addListener(
  AiDisclosureEvents::SUGGESTION_STATUS_CHANGE,
  function (SuggestionStatusChangeEvent $event): void {
    // $event->getNewStatus() is one of SuggestionStorage::STATUS_ACCEPTED
    // or STATUS_DISMISSED when a decision was actually made.
  },
);

This submodule is one subscriber among any number of others. It does not change what the event carries or when it fires.