Repository navigation
feat(reporting): issueObserver — notify the seller when a reporting issue opens, escalates or resolves (#3648) - #3654
Conversation
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean additive PR with no blocking or medium findings.
Checked:
- Adds TypeScript
issueObserverfor Reliable Reporting (transactional outbox + dispatcher + PG migration 0012), mirroring Python's issue_observer. - Core logic verified: resolve-never-before-open in
planReportingIssueEventsV1, at-most-one-per-occurrence/recording-order inclaimOn(SKIP LOCKED) and the memory head-map, exactly-once via UNIQUE(issue_id,kind) + derived eventId, sub-ms-preservingaddReportingInstantOffset. - Newly-unconditional
commitRevisioncallback is a no-op change (appendChange gated on changeFeed;port?.short-circuits). - Operational readiness solid: 10s observer timeout, capped backoff, errors routed to onError, per-event retry.
- Both changesets present and correctly scoped minor/additive; package boundary respected.
Gate checks: no no-auto-approve team match; gated_paths false; high_risk false. review_decision is REVIEW_REQUIRED but gated_paths is false, so row 2 does not apply. No critical/high/medium findings — falls through to row 9 (approve).
Non-blocking note: PostgreSQL-specific SQL is exercised only by the env-gated reporting-issue-events-pg.test.js, consistent with the repo's other PG-store tests; the in-memory suite covers shared logic.
|
CI note: |
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — delta pass over 4 files (changeset note, REPORTING-LEDGER.md, one conditional line in packages/reporting-storage/src/service/index.ts, new PG integration test). No critical/high/medium findings.
Checked:
- Production-service change adds REPORTING_ISSUE_EVENTS_MIGRATION to applyMigrations only when issueObserver is set — same conditional pattern as rowStorage (0004) and changeFeed (0005), with the migration unconditionally present in the canonical MIGRATIONS list that checkSchema enforces. issueObserver flows to core service via ...rest, gated on issueEventsReady(). Right shape.
- Changeset is minor on @adcp/reporting-storage per the experimental-schema policy; docs accurate.
- No witness-not-translator concerns, no transport-client reimplementation, no doc-link drift.
Gate checks: no no-auto-approve team match; gated_paths is false (row 2 does not fire despite review_decision REVIEW_REQUIRED); high_risk false; no deletions in sensitive paths. Prior decision was approve with no findings; nothing new surfaced. Rows 1–8 do not fire → row 9 approve.
…3648) Record CONSUMER_STATUS_MISMATCH occurrence transitions (opened, escalated, resolved) in a transactional outbox beside the ledger writes that cause them, and deliver them to an issueObserver at least once from runCycle. Python parity with adcp-client-python#1874 and #1875. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sets (#3648) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…memory and PostgreSQL (#3648) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createPostgresReliableReportingProductionService adds the issue-event migration to setup.migrations when issueObserver is set; test delivery, onError and redelivery through the production composition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ed12911 to
74b92d0
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean, additive issue-observer feature with no blocking or medium findings.
What I checked:
- No findings at any severity (no critical/high/medium).
high_risk: false;gated_paths: falseso row 2 does not apply despitereview_decision: REVIEW_REQUIRED.- No no-auto-approve team match.
- Witness-not-translator honored: render yields
issue: nullrather than fabricating upstream data. - Changesets correct (sdk minor + reporting-storage minor; no hand-edited package.json version).
- Storage semantics verified:
UNIQUE(issue_id,kind)+ON CONFLICT DO NOTHINGfor exactly-once across replicas;FOR UPDATE SKIP LOCKEDclaim; 0012 migration is additive. - Operability: 10s observer timeout via
Promise.racewith timer cleared, lease sizing, backoff, failures routed to onError without failing buyer call/cycle, 7-day prune. - Tests exhaustive including gated PostgreSQL integration.
Prior decision was also approve for an earlier head; current run confirms no regressions. Decision table: rows 1–8 do not fire → row 9 approve.
…open one (#3648) Two consumers may reuse one reporting_status_id on one obligation, and the published issueId does not name the consumer, so occurrences and event uniqueness are now (issueId, consumerId[, kind]) and the eventId hashes the consumer. The outbox doubles as the occurrence ledger, so retention prunes only whole resolved, fully delivered occurrences: an open occurrence keeps its opened row for a later resolution and for escalation windows longer than the retention. Migration 0012 changes in place (unreleased). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#3648) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…abort (#3648) A resolved occurrence whose statement is still current can be derived again, so pruning it would let a later write record its delivered eventIds again. Prune only occurrences that are resolved, fully delivered past the retention, and whose statement was superseded or whose obligation is gone. The dispatcher now releases the rest of a claimed batch when the cycle is aborted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… resolved principal, abort (#3648) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass, no blocking or medium findings.
Checked:
- Re-keying issue occurrences on
(issueId, consumerId):reportingIssueEventIdV1now hashes[tag, issueId, consumerId, kind], and every occurrence-membership check (plan, open-set, escalation sweep, claim head, memoryissueOccurrenceKey) was updated in lockstep, matching the UNIQUE and the new(issue_id, consumer_id, seq)index. - Pruning removes only whole closed occurrences (resolved, all events past retention, statement no longer current via
currentConsumerLeaf/is_currentor obligation gone) and never an open/anchor occurrence; postgres CTE and memory grouping agree. - Dispatcher reads the obligation for
resolvedto carryprincipal, retries on read throw, returns undefined (no retry) when obligation is genuinely gone, and releases the batch tail on abort. - Test coverage is sound: keyed occurrences, occurrence-whole prune, principal-on-resolved (incl. obligation-gone), and abort release.
- Both changesets present and
minor— correct for an experimental, wholly-new surface.
Decision path: no critical/high (row 1 no), gated_paths false (row 2 no), high_risk false (rows 3/5 no), no medium findings (rows 4/8 no), prior decision was approve (row 6 n/a), no team gate (row 7 no) → row 9 approve. Prior run also approved on clean findings; this run remains clean.
Summary
Closes #3648. A seller can now learn, through a supported callback, when a
CONSUMER_STATUS_MISMATCHopens, escalates pastconsumer_mismatch_escalation_seconds, or resolves. This is the TypeScript side of the Python SDK'sissue_observer: storage outbox adcontextprotocol/adcp-client-python#1874, SDK observer adcontextprotocol/adcp-client-python#1875.How the TypeScript ledger maps onto the Python design
The Python outbox hangs off
reporting_issue_lifecycle, a persisted per-chain occurrence table with generations and an operatorset_issue_state. TypeScript has neither: a mismatch is a pure read-time projection (projectReportingConsumerStatusMismatchV1), and itsissueIdishash(obligation, reporting_status_id). So the occurrence here is that projection, with the seller-health suppression removed, as in Python'sconsumer_statement_conflicts. It is re-derived at the writes that can change it.openedsyncConsumerStatusBatch(a new conflicting leaf) orcommitRevision(a restatement makes a leaf conflict, or the first revision anchors one)resolvedsyncConsumerStatusBatch(the superseded leaf's occurrence) orcommitRevision(a leaf that no longer conflicts)escalatedsweepIssueEscalationsonrunCycle, after re-settling the obligation(issueId, consumerId). The publishedissueIdnames the obligation and the causingreporting_status_idbut not the consumer, and two consumers may legally reuse one status id.UNIQUE (issue_id, consumer_id, kind)makes each record exactly once.eventIdisrpie_plus the first 40 hex characters ofsha256(canonical_json(["reporting-issue-event-v1", issueId, consumerId, kind])), pinned by a vector computed with Python's canonical JSON. Python hashes[tag, issue_id, kind]; itsissue_idis already consumer-scoped (the occurrence key includesconsumer_id), so it has no collision.SKIP LOCKEDon PostgreSQL). The observer gets a 10 s timeout. Retries back off from 30 s, doubling to 1 h, with no limit. An aborted cycle releases the rest of its batch.eventIdagain. The Python counterpart re-escalates still-open occurrences after its prune: fix(reporting): issue-event retention prune re-escalates still-open occurrences every 7 days adcp-client-python#1885.ReportingIssueObserverErrorwithcomponentissue_observer,issue_eventsorissue_events:snapshot. It goes to the scheduler'sonErrorand intorunCycle(...).issueEvents.errors. It never fails a buyer call or the cycle.issueis rendered asget_reporting_statusshowed it atoccurredAt: revisions created by then, persisted issues open then, and the escalation window. It isnullforresolved, while the read suppressed it, or when the evidence is gone. Rendering never throws. Managed Delivery is left out.principalcomes from the obligation on every kind,resolvedincluded, and is omitted only once the obligation is gone.Escalation and delivery need
runCycle.openedandresolvedare recorded with the writes either way. WithoutrunCycle(start()or your own scheduler), though, the observer is never called andescalatedis never recorded. Python behaves the same: its worker turn both sweeps escalations and delivers.API
@adcp/sdk/reporting/serviceissueObserveroption. It is refused at construction against a store without the capability, and at the first cycle before the table exists.runCycle(...).issueEvents.issue_observer.@adcp/sdk/reporting/ledger(experimental store surface)ReportingIssueEventStoreV1capability andsupportsReportingIssueEventsV1.reportingIssueEventIdV1,reportingConsumerMismatchOccurrenceV1,planReportingIssueEventsV1,reportingIssueEscalationV1,renderReportingIssueEventV1.createReportingIssueEventDispatcherV1andReportingIssueObserverError.memoryStorage()implements the capability.@adcp/reporting-storagePostgresReportingLedgerStoreimplements it.0012 reporting_issue_events: one new table and its indexes, additive. The SQL is exported asREPORTING_ISSUE_EVENTS_MIGRATION.Storage contract and migration
TypeScript has no
STORAGE_CONTRACTconstant. The Python side kept its contract at2because its0008is additive. This change is likewise one additive migration, numbered0012because #3651 takes0011. No existing table, key, function or trigger changes, so workers of the previous release run beside it and record no events.postgresStorage().checkSchema()names0012as pending untilnpx adcp-reporting-storage migrateruns. That matches Python, which refuses to start while a non-contract migration is pending. The semantics stay aligned.Parity gaps (known, deliberate)
acknowledged/waivedare never emitted. They are in the kind union for type parity. The TypeScript ledger has no operator issue-state API (Python'sset_issue_stateand its bilateral waiver binding). TSDoc and the guide say so.consumer_statement_conflictsresolvescontent_mismatch,revision_missingandunreadablewhen the seller's record comes to agree. The TypeScript read projection is monotone in seller evidence, so only a buyer supersession resolves one.commitRevisionalready re-settles against the read projection and recordsresolvedwhen it stops conflicting. That branch is wired and tested at store level, and it will fire once fix(reporting): CONSUMER_STATUS_MISMATCH never resolves through a seller-side change (TS read-path parity with Python's consumer_statement_conflicts) #3653 changes the projection. fix(reporting): CONSUMER_STATUS_MISMATCH never resolves through a seller-side change (TS read-path parity with Python's consumer_statement_conflicts) #3653 is a buyer-visible read change, so it stays out of this PR.issueIds are per causing statement; Python's are per chain occurrence with generations. TypeScript'seventIdalso hashes the consumer. The two SDKs never publish equal ids for one disagreement, so observers should not compare them across SDKs.createPostgresReliableReportingProductionServicepassesissueObserverto the core service, whoserunCycleandstart()it drives. When the observer is set, it also addsREPORTING_ISSUE_EVENTS_MIGRATIONtosetup.migrations. Python currently refusesissue_observerwithproduction=, and the parity follow-up that adds it there is feat(reporting): support issue_observer on the production= service path adcp-client-python#1873. A production-service test covers delivery, a failing observer reachingonErrorwithout failing the cycle, and redelivery.Tests
test/lib/reporting-issue-observer.test.js(42 tests, memory store, dispatcher, service):eventIdgolden vector (computed with Python's canonical JSON over[tag, issueId, consumerId, kind]), and exact instant arithmetic.commitRevision; a negative status opens at ingest, or at the first revision commit when filed before any revision; supersession resolves the old occurrence, and opens a new one when the new leaf also conflicts; exact orunchangedreplays record nothing.attempt2; a stale lease can't complete; release backoff; prune; account scoping.eventId; async timeout; a snapshot-read failure released, not delivered; rendering never throws.runCycle(...).issueEvents.eventIds.reporting_status_idget independent occurrences: one resolves while the other escalates.principalappears onopenedandresolved, and is omitted once the obligation is gone.packages/reporting-storage/test/reporting-issue-events-pg.test.js(24 tests, PostgreSQL 16):0012is additive and idempotent; with the table absent, ingest and commit still work and record nothing.principal.packages/reporting-storage/test/reporting-issue-observer-production-pg.test.js(3 tests):createPostgresReliableReportingProductionServicewithissueObserver.setup.migrationscarriesREPORTING_ISSUE_EVENTS_MIGRATION, and the observer starts throughapplyMigrationsalone.runCycledelivers it (result.core.issueEvents).onErroras aReportingIssueObserverError.eventIdwithattempt2.reporting-storage-migrations.test.jsasserts that0012is the last migration.request-signing-applies-when: 374/374.typecheck,typecheck:reporting-storage,lint:boundaries,format:check,ci:doc-linksandci:docs-checkare clean.Changesets
@adcp/sdkminor@adcp/reporting-storageminor, with a migration note🤖 Generated with Claude Code