ADR 187 · Accepted
ADR-187: AI-write provenance is announced, not implemented
Context
ADR-182 made a write name the record it produced, so the extension finally knows — as a queryable fact rather than as free-form JSON inside a tool call's arguments — that run R wrote pages:42. That fact was built for one reader, the observed-outcome derivation of ADR-185, and it answers a second question nobody has asked the extension to answer itself: which records on this installation were written by an AI, and how should that be shown.
The answers people want are not the same answer. An Article 50 transparency label on the frontend, a badge in the page module, a line in an existing editorial audit trail, a nightly report to a compliance team, a hand-off to an external system that already owns disclosure — each is a different artefact with a different owner, and every one of them is a policy decision belonging to the installation rather than to this extension.
Two paths were open. Implement content labelling here, with configuration for the cases above; or state the fact and stop. The first would put this extension in the business of deciding what a site says about itself, which is the site's business, and would need a configuration surface for every variant — the shape ADR-064 already refuses for privacy policy.
Decision
The extension says that an AI wrote a record. It never says what anyone should do about it.
A final readonly PSR-14 event, NetresearchNrLlmEventAfterAiRecordWrittenEvent, carries three values: the correlation id (the run's uuid, ADR-153), the RecordReference of the row, and a WriteKind.
No listener ships in this extension, and none is planned. An event with a shipped listener is a feature with an extension point bolted on; this is an extension point.
No record payload. Not the field values, not a before/after, not a rendered excerpt, not even the tool's name. A payload is a copy, and a copy of editorial content is a second place for it to leak from and a second place for it to go stale. The reference names the row; a listener reads it under its own permissions at the moment it needs it.
The kind is a new enum, not ToolEffect. That enum classifies a write by whether repeating it is safe, because its reader is the at-least-once queue (ADR-111). A consumer deciding what to say about a record needs to know whether the record was brought into being or changed, and the two axes cross: move_content_element is an idempotent UPDATED, attach_file_to_content_element a non-idempotent CREATED. Neither is derivable from the other, so the tool declares both.
Two cases, no third. A deletion would need one and no builtin deletes. The case arrives with the first deleting writer — a value nothing emits is a value nothing can be tested against.
Where it is dispatched, and why there
From ToolLoopService::invoke(), the single call site of ToolInterface::execute() in this extension. "Exactly once per successful write" is then a property of the code's shape rather than of seven writers remembering to dispatch — and it stays true for the eighth.
That placement has three consequences worth stating rather than discovering:
It fires before the run trace persists the step for the same call. A listener must not try to join the run's trace for this write; it is not there yet. The event is self-sufficient by design, which is what lets it be dispatched at the earliest honest moment instead of the most convenient one.
Every editorial write reaches it through the resume path. ToolApprovalRule::requiresApproval() makes every tool declaring a write effect approval-bound, so a write never executes on a run's first pass: the loop suspends, a human approves, and the call executes on resume. Both paths go through invoke(), which is exactly why the choke point was chosen over the four call sites around it.
A throwing listener is logged, not propagated. This is the one place in the loop where foreign code runs after a side effect. The write has landed and a human has approved it; letting a consumer's label or report take the run down would turn a completed editorial write into a failed one, and the model's next move on a failed write is to try it again. The full Throwable goes to the log, so the failure is visible without being contagious.
It can fire twice for one record. The agent queue is at-least-once (ADR-104): a reaped run may re-execute, and an idempotent write that runs twice dispatches twice with the same correlation id and reference. A listener that must act once per record deduplicates on that pair. A non-idempotent write is never auto-retried, so it cannot double this way.
What it does not carry, and where that is decided
The event names the run, not the person. Which backend user a run acted for is AiActorContext's business and is persisted on the run; a listener that needs it resolves the run by its uuid. Putting the actor on the event would make a compliance consumer's convenience into a second copy of an identity this extension already stores once, and ADR-064 places that decision with the privacy model rather than with each emitter.
Consequences
ToolResult::withWriteTarget() gains a required second parameter. It is on the frozen surface (ADR-127) and shipped in 0.34.0, so the change is announced as breaking. The alternative — an optional kind defaulting to something — would be the extension guessing what a tool did, and the tool is the only party that knows whether it minted the uid or was handed it.
A target and a kind are set by one method and by no other, so "a target without a kind" is not a state ToolResult can be in. That is what lets the dispatch site announce a write without inventing a default.
A write made outside a persisted run announces nothing. A bare ToolLoopServiceInterface consumer has no correlation id, and the event refuses an empty one: a provenance record pointing at no run looks like a record while being none.
Consumers gain a reason not to poll tx_nrllm_agentrun's trace, which is an internal shape this extension changes freely.