Skip to content

Recording disclosures

To record AI involvement from code, call ai_disclosure.recorder, which implements DisclosureRecorderInterface. That interface is the only class a calling module needs to know.

suggest()

suggest() leaves a non-binding suggestion for the editor to Accept or Dismiss on the entity form. It never changes the field.

\Drupal::service('ai_disclosure.recorder')->suggest(
  $entity,
  'ai_partly_assisted',
  'my_module',
  'Three paragraphs were rewritten by the assistant.',
);

The entity must already be saved, and a second call from the same source on the same entity and language replaces the first one.

The note is shown to the editor as text. Markup in it is escaped, not rendered, so a link or an emphasis written there reaches the form as the characters that spell it.

apply() and applyProfile()

apply() and applyProfile() write the disclosure directly, for headless pipelines and cron. Both refuse to downgrade: the write happens only when the requested grade's severity is strictly higher than the entity's currently effective grade. Both set the human review flag to FALSE, because an automated caller cannot attest that a person reviewed anything.

\Drupal::service('ai_disclosure.recorder')->applyProfile(
  $entity,
  'machine_translation',
  'my_module',
);

From inside hook_entity_presave(), pass $save = FALSE. The recorder then changes the field values on the entity object and leaves persistence to the save cycle already running. Calling $entity->save() there would recurse:

\Drupal::service('ai_disclosure.recorder')->apply(
  $entity,
  'ai_translated',
  'my_module',
  NULL,
  FALSE,
);

What a binding write leaves behind

A write that actually happens is recorded in the same ai_disclosure_suggestion table suggest() writes to, as a row with status accepted carrying the $source and the $note. It is provenance for a decision already made, not a proposal: the editor's pending callout and the report's pending-suggestion count both look at pending rows only, and the note is never shown to a reader. applyProfile() records the grade the profile resolves to. Which profile it was is on the field itself.

Nothing is recorded when the downgrade rule refuses the write, and nothing is recorded when the write happens during an entity's first insert, because the row is keyed by an entity ID that does not exist yet.

A suggestion left by suggest() only turns accepted when the entity form that shows it is actually saved with that suggestion's grade still selected - clicking Accept in the editor's callout stages the intent on the form, it does not write the row. ai_disclosure_settle_accepted_suggestions() checks, after the save, that the field still carries the accepted grade before flipping the status. A save that changes the grade again, or that never happens, leaves the suggestion pending.

What accepted has meant, by version

As of this version, accepted means one thing only: a write actually happened, through suggest()'s form settlement or through apply() / applyProfile(). Rows a site wrote before this version may not carry that same guarantee. Read them with that doubt, they are not retroactively reconciled.

Errors never break the caller's save

An unknown grade, an unknown profile or a bundle without the field never breaks the caller's save: they log a warning on the ai_disclosure channel and return. The one exception is suggest() on an unsaved entity, which throws \InvalidArgumentException, because there is no ID to key the suggestion row on.

The $source argument

$source is the calling module's machine name, for example ai_automators or ai_translate. Every recorder call stores it, so the ai_disclosure_suggestion table is where a site answers "which module said this, and why". The compliance report does not expose that column (it reports on the field, not on this table), but the suggestions listing does, so a source that invents its own naming spoils the record for whoever reads that page or queries the table directly.

What Views can build from suggestions now

The report's "Pending suggestions" column is a count: one number per entity, pending rows only, computed against whichever field the row is about. It answers "how many suggestions is this entity still waiting on", and nothing about grade, source or note.

The ai_disclosure_suggestion table itself is a Views base table in its own right, behind the access ai disclosure suggestions permission. That is a separate, restricted permission, because a raw row exposes a note a third-party module wrote, which the report never shows. A view on it is rows: every suggestion, in every status, with its grade, source, note and the user recorded against it, and a relationship toward whichever entity type it names. That is where a site sees accepted and dismissed rows at all, and where "which module suggested what, and why" in $source and the note actually gets read back. The shipped suggestions listing is one such view. A site can build its own on the same table.

A suggestion row's langcode follows the translation it was recorded against: deleting that translation deletes the row with it, in both kinds of view.

Reacting when a suggestion is decided

Accepting or dismissing a suggestion fires an event, so a module can follow those decisions without polling the table. The name is AiDisclosureEvents::SUGGESTION_STATUS_CHANGE and the object is a SuggestionStatusChangeEvent, carrying the old and the new status together with the row's identity: its ID, the entity type, entity and langcode it is about, the grade, the source, the note, and the uid stored on the row.

That last one is the uid the suggestion was recorded under, not the person clicking Accept. For who is acting right now, read current_user inside the subscriber.

Two silences are deliberate. An ID matching no row fires nothing, and neither does a write that sets the status a row already had. The update still refreshes the row's changed timestamp, but nothing decided, so nothing is announced. The event is dispatched after the write has succeeded, which means a subscriber reacts to a change and cannot veto it.

A subscriber that throws cannot break the change either. The exception is caught where the event is dispatched and written to the log, naming the suggestion, the two statuses and the subscriber that failed, and the call returns as it would have.

The audit trail submodule is the first consumer: it turns each of these into a row that says who decided what, and when.

Calling it from an agent

An AI agent reaches this service through the ai_disclosure_tool submodule, which wraps it in one plugin for the contributed Tool API module. The plugin adds no rule of its own: the invariants above are what an agent gets. What it does add is the access check the service leaves to its caller, and a site setting that keeps binding writes off until someone turns them on. See The Tool plugin.