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.