ADR-0142: Denials record their result item, and old denials project from what they recorded¶
- Status: Accepted by owner request on 2026-09-29 (runtime repair)
- Date: 2026-09-29
- Related: ADR-0003 (amended: payload versions and upcasters), Milestone 4 approvals
- Detailed design:
docs/plan/event-log-and-persistence.md
Evidence¶
On 2026-09-29, on main at 344103c8, a chat broke after the owner denied an
approval: the next message posted to that session returned HTTP 500
internal_error. Since Milestone 4 (dc95722c) the approval and policy denial
path appended tool.call.denied with the call's name, id and reason code but
no result_item. Session history requires a result item for every tool result
event, because the provider must see a result for every call it replays. The
projection therefore raised on every later submission in that session,
including messages from paired surfaces. Trajectory export, skill review and
the in-memory history read the same projection function and failed the same
way. Refusals, which also append tool.call.denied, always carried the item.
Production sessions already hold the itemless events.
Decision¶
tool.call.deniedpayload version 2 requiresresult_item: the item the denial gave the model, which the invocation row already stores. The tool pipeline's one event writer stamps version 2 on every denial and refuses to append a denial without its item.- A pure upcaster takes version 1 to version 2 by making a missing
result_iteman explicitNone. A refusal's recorded item passes through unchanged. No stored payload is rewritten. - Session history renders an explicit-
Nonedenial from the recorded facts alone: an error result for the recorded call id whose single text part is{"status":"denied","action":<name>,"reason_code":<reason code>}. The narration and the tool's output trust were never recorded, so the item has no message and is labelledexternal_untrusted, the level the platform gives content whose provenance it did not record. A denial whose item is absent rather thanNone, or whose name, call id or reason code is missing, still fails the projection, as does any other tool result event without an item.
Alternatives rejected¶
- Rewriting the stored events. Stored payloads are immutable; schema evolution is expressed only as upcasters.
- Upcasting to the full original item. It needs the tool's output trust and the narration catalog as it stood when the event was written. An upcaster may neither look them up nor invent them.
- Projecting the invocation row's stored item. It is exact, but it makes a projection read another table and repairs only the PostgreSQL session history, while trajectory export and skill review decode the same events without that table.
- Dropping the denied call from history. The call would be orphaned, and context assembly rejects an orphaned call.
Consequences¶
- Affected sessions accept messages again once the release is deployed, without a migration or a projection rebuild. The failed projection never advanced its watermark past the denial, so catch-up resumes there, and no stored history row holds an itemless denial, so the builder version is unchanged.
- Replayed old turns show the model a terser denial marked untrusted. The label only lowers trust, and the write-taint rule reads the active turn alone, so an old denial in an earlier turn does not taint a new request.
- Code older than this release fails loudly on a version 2 denial, as the rule against partial decoding of newer payloads requires. The API and the workers run the same release.
Verification¶
tests/integration/test_approval_denial_history_postgres.py: the HTTP reproduction and a stored version 1 denial. Onmain344103c8 both failed with the 500 (ValueError: tool.call.denied has no result item); both pass.tests/unit/test_denied_tool_history.py: the in-memory journey, which failed the same way on the unrepaired executor, and the projection's accepted and rejected shapes.tests/contract/test_upcaster_contract.py, the check forgate.event.upcaster_totality: recorded version 1 approval denials and refusals, version 2 pass-through, and rejection of version 3.