---
title: "ADR-185: An observed outcome is derived from history, inside a window"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-185@0.35"
source: "Adr/Adr185ObservedOutcomeFromHistory.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-185: An observed outcome is derived from history, inside a window

-   *Status:* Accepted
-   *Date:* 2026-08-31
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-176](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-176@0.35) split the per-call outcome into two sources and
shipped the explicit one. The observed one — did the generated text survive
contact with an editor — was blocked on a fact nobody persisted: which record a
tool write produced. [ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.35) persists it, as a `tool_write`
step naming a table and a uid.

So the join exists now. What it joins to, and what the answer MEANS, is not
decided, and three things surfaced on the way here that decide it.

**The correlation id is the run's uuid.** `tx_nrllm_agentrun` has no
`correlation_id` column; `AgentRunReference::correlationId()` returns the
run uuid, and every provider round-trip of that run reports under it. An
observed outcome therefore attaches to a RUN, not to one round-trip. ADR-176
says "per-call" and this is narrower than that phrase suggests; a five-round run
that wrote once yields one observed row covering all five.

**There is no UNKNOWN.** `CallOutcome` has five cases and none of them says
"looked, could not tell". The absence of a row cannot stand in for it, because
absence also means "the window has not closed yet" — two facts one storage state
cannot hold, which the engineering constitution's sixth principle forbids.

**The write target names a record, not the fields it wrote.** ADR-182 kept it to
two scalars deliberately. That is enough to ask whether a record changed and not
enough to ask whether OUR text changed.

## Decision

**An observed outcome is derived once, after a window, from \`\`sys_history\`\`, and
says UNKNOWN whenever it cannot say better.**

-   **A new \`\`UNKNOWN\`\` case** on `CallOutcome`, in the observed source. It
    is written, not implied: a derivation that ran and could not decide is a
    different fact from one that has not run.
-   **A window, configurable, defaulting to seven days.** Derivation happens after
    it closes, once per write, and the answer is final. Before it closes there is
    no row — which is why UNKNOWN has to be a value rather than an absence.
-   **The classification, from history rows AFTER the write's own:**

    ```text
    no row, record still present     → ACCEPTED_UNCHANGED
    MODIFY                           → EDITED
    DELETE, or the record is gone    → DISCARDED
    history trimmed past our write   → UNKNOWN
    ```

    The last line is the subtle one. History is trimmed oldest-first, so what
    proves our write's row survived is not that SOME row exists at or before it —
    an older survivor answers that for a write whose own row is long gone — but
    that the OLDEST retained row is at or before it.
-   **One outcome per RUN, combined from every record it wrote**, by an explicit
    precedence:

    ```
    DISCARDED > EDITED > UNKNOWN > ACCEPTED_UNCHANGED

    ```

    A run can call more than one write tool and they share a correlation id, so
    judging only the first would let a run whose second write was deleted be
    recorded as accepted. A known negative outranks an unknown; and
    `ACCEPTED_UNCHANGED` may only be claimed when every write of the run is
    known to have survived untouched.
-   **The already-answered runs are excluded in the QUERY**, not filtered after
    it. A fixed page of the oldest writes would stop advancing the moment a full
    page of them had been answered, and the command would then report no work
    while writes piled up behind it.
-   **A CLI command, schedulable**, like the four purge commands this extension
    already ships. Never the request path — ADR-174's rule for the cost signal
    holds here for the same reason.

## What EDITED means, exactly, and what it does not

**Any modification by a human, in the window, after our write.** Not "a
modification to the text we generated" — that question needs the field list, and
[ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.35) deliberately does not carry one.

The consequence is over-reporting, and it is stated rather than hidden: an
editor who fixes an unrelated field on the same page inside the window makes our
write read as EDITED. On a busy page that is wrong about the model and right
about the record.

The alternative — widen the write target to name the fields — is a real option
and is not taken here, because ADR-182's revisit clause is explicit that the
shape changes when a reader asks, and this reader can begin without it. The
first measured rate of EDITED is what says whether it must.

## What this does not build

**No promotion rule.** [ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35)'s second criterion becomes
computable; whether a delta justifies promoting a canary is a decision this
record does not make.

**No editor-level readout.** ADR-176's rule stands: no `be_user` on the
outcome row, no per-person view. The question is how the model did, not who
edited.

**Approval is still not quality.** [ADR-176](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-176@0.35) separated them and
[ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@0.35) added a refusal that is explicitly not a signal. A
stale-refused approval produces no outcome at all, because no write happened.

**No second definition of "changed".** ADR-184 compares preview lines to decide
whether an approval still holds. This compares history rows to decide what
became of a write. Same word, different question, different data — and they must
not be folded together, for the reason ADR-136 gave when it refused to fold the
turn digest into a resource check.

## Consequences

-   ✅ `ACCEPTED_UNCHANGED`, `EDITED` and `DISCARDED` acquire a writer, and
    `CallOutcome::isImplemented()` starts returning true for them.
-   ✅ A derivation that cannot decide says so, so "no signal" and "no answer" stay
    apart in the data.
-   ⚠️ The outcome is per RUN, because the correlation id is. Any readout that
    calls it per-call is describing something narrower than its name.
-   ⚠️ EDITED over-reports by construction until the write target names fields.
    A readout that ignores this will read an editorial habit as a model deficit.
-   ⚠️ `sys_history` has its own retention, set by the installation and not by
    this extension. A window longer than that retention yields UNKNOWN rather than
    ACCEPTED_UNCHANGED — which is the whole reason UNKNOWN exists.

## Revisit when

-   **EDITED is measured and high.** That is the trigger for carrying the written
    field names, and the number is what justifies it rather than the suspicion.
-   **The precedence proves wrong in practice.** A run whose several writes end
    differently is recorded by the worst of them, which is a choice and not a
    measurement. If the mix turns out to matter, the outcome has to become one row
    per write — and that needs a key the outcome table does not have.
-   **A caller needs the outcome per round-trip.** That needs a per-call
    identifier the runtime does not have today, and inventing one is a larger
    change than this record.
