Skip to content

feat(reporting): notify the adopter when a reporting issue opens, escalates or resolves #1258

Closed

Description

@bokelley

Summary

On 8.0.0b18, a seller has no way to learn that a reporting issue opened. This matters most for CONSUMER_STATUS_MISMATCH, which only the submitting caller can see.

  • Where issues open: ConsumerStatusIngest._open_issue_on_first_observation opens the occurrence when a sync_reporting_status statement lands. The get_reporting_status lifecycle intents open it on a read. Both go through store.ensure_issue_opened.
  • What the SDK exposes: neither path calls back into the adopter, and ReliableReportingService.__init__ has no hook for it. The rendered ReportingIssue, with responsible_party, recommended_action, severity and opened_at, only exists inside that caller's status response.

So a mismatch the seller must act on (responsible_party: seller or provider) is invisible to the seller's operators unless the buyer contacts them.

What ESA does today

ESA scans every get_reporting_status response it serves for open CONSUMER_STATUS_MISMATCH issues attributed to the seller or provider. It logs them and sends a Sentry event, de-duplicated per issue_id per process. That has two problems:

  • It depends on the buyer. A buyer that files a statement and never reads status again is never alerted on. _open_issue_on_first_observation exists precisely to handle that unattended case.
  • It is tied to reads. It fires on a read, not when the occurrence opens. Escalations past consumer_mismatch_escalation_seconds are only seen if someone polls, and the de-duplication has to be rebuilt in every process.

Ask

An adopter hook on the service, called once per lifecycle transition, after the store commits:

async def on_issue(event: ReportingIssueEvent) -> None: ...

ReliableReportingService(..., issue_observer=on_issue)

where ReportingIssueEvent carries:

  • kind: opened, escalated, acknowledged, waived or resolved
  • the rendered ReportingIssue
  • account_id and consumer_id (for caller-scoped issues)
  • the causing reporting_status_id

Requirements:

  • Exactly once per transition across replicas. reporting_issue_lifecycle already makes opening idempotent (reporting_issue_lifecycle_one_live), so the transition that inserts the row can fire the event.
  • Fires on ingest. The event should fire from sync_reporting_status, not only from get_reporting_status, so an unattended statement still reaches the seller.
  • Observer failures don't fail the request. An exception in the observer is reported through worker_error_handler or logged, and never fails the buyer's call.
  • Escalation fires on time. escalated fires when the escalation boundary passes. This needs the worker to evaluate open occurrences on its turn, because no read may happen.

ESA would then swap its response scan for this hook.

Refs #1240.

Activity

  1. added
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Sep 30, 2026
  2. bokelley commented on Sep 30, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Feature request
    Bucket(s): service layer (src/adcp/reporting/service.py, ledger/consumer_status.py)
    Status: ready-to-implement

    What the experts said:

    • code-reviewer: Non-breaking as proposed, but ensure_issue_opened has no "was_new" discriminator — firing the observer unconditionally would break the exactly-once guarantee across replicas. Escalation also uses a separate time-based worker path, not the ingest path.
    • dx-expert: Naming should follow the *_handler convention (reporting_issue_handler); production= guard is the key blocker for ESA — issue_observer must be explicitly carved out of it or threaded through ReportingProductionSupport.

    My take: Well-scoped, clearly specified, non-breaking. The two design decisions to settle before coding: (1) does reporting_issue_handler work alongside production=, and if so, thread it through ReportingProductionSupport._compose; (2) ensure_issue_opened needs a was_opened: bool discriminator so the observer fires only on the actual INSERT, not on read-path idempotency hits.

    Implementation scope:

    • src/adcp/reporting/ledger/models.py — add ReportingIssueEvent as @dataclass(frozen=True) with kind: Literal["opened", "escalated", "acknowledged", "waived", "resolved"], issue: ReportingIssue, account_id: str, consumer_id: str | None, reporting_status_id: str
    • src/adcp/reporting/ledger/store.py + pg.py — extend ensure_issue_opened return to carry was_opened: bool (True only from the INSERT branch in _ensure_issue_opened_on); no change to the status.py/status_snapshot.py read-path callsites (they must not fire the observer)
    • src/adcp/reporting/ledger/consumer_status.py — add issue_observer field on ConsumerStatusIngest; fire after ensure_issue_opened at line ~299 only when was_opened is True; wrap in try/except, route errors via worker_error_handler
    • src/adcp/reporting/service.py — add reporting_issue_handler: Callable[[ReportingIssueEvent], Any | Awaitable[Any]] | None = None param; type alias ReportingIssueHandler; decide production= guard (carve out or add to ReportingProductionOptions); export in __all__
    • Worker turn (wherever escalation threshold crossing is acted on) — fire observer with kind="escalated"; this is a separate code path from ingest
    • Required checks: pytest on reporting ledger/service tests, mypy src/, ruff check .
    • Non-breaking: new optional param with None default; new frozen dataclass; new try/except-guarded callbacks

    Triaged by Claude Code. Session: https://claude.ai/code/session_01YCsVHyCBbgzLqR1fQvoz1j


    Generated by Claude Code

  3. added
    enhancementNew feature or request
    and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Sep 30, 2026
  4. bokelley commented on Nov 9, 2026

    @bokelley
    ContributorAuthor

    Fixed by #1875 and #1874 on next. The remaining production= gap is tracked in #1873. It didn't auto-close because main is the default branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions