Repository navigation
feat(reporting): rc.3 consumer-status hardening (seller ledger) - #2914
Conversation
| // A buyer that owes a status and has not posted one is a counted | ||
| // unknown, never a conflict: it creates no issue, changes no health, | ||
| // and is invisible to every other caller. | ||
| const consumerStatusPending = |
There was a problem hiding this comment.
Medium: consumer_status_pending ships with no test coverage. The new count computed here (empty caller-scoped chain past recoveryDeadlineAt), its summary wiring at handler.ts:333, and the wire-validation bound in reporting-status-response.ts:797 (> total rejection) are all new branches, and grep consumer_status_pending test/ returns nothing. Everything else in this PR is exhaustively covered (357-line projection suite); this advertised obligation-count field is the one path where a regression — wrong count, off-by-recovery-window, or a bound that never fires — lands silently on the summary view. Add a handler test asserting the count and a guard test for the > total rejection.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — AdCP 3.2.0-rc.3 consumer-status hardening verifies cleanly.
Checked:
- Core projection logic: caller-scoped mismatch, immutable-fact openedAt/issueId, first-supersession grace anchor, escalation-over-grace precedence, and fail-closed authoritative_party refusal — all verified against the 357-line test suite.
- Type plumbing, caller-scoping, and wire-shape fidelity (witness-not-translator preserved; no fabrication or re-shaping at seams).
- Changeset present and correctly typed for the wire-impacting
src/lib/**changes.
Medium findings (non-blocking):
src/lib/utils/reporting-status-response.ts— the newconsumer_status_pendingobligation count and its> totalwire-validation bound have no test coverage.
With a single medium finding and no critical/high findings, no gated paths, no team gate, and no escalation triggers, this falls through to row 9 (approve). The uncovered validation bound is worth a follow-up test but does not block.
f8efe98 to
2506b19
Compare
219b945 to
3fceb4b
Compare
2506b19 to
5c1472a
Compare
Review stack results —
|
| Finding | Stacks |
|---|---|
Persisted CONSUMER_STATUS_MISMATCH leaks across consumers |
security (H1) |
| Grace-window obligation unreachable under every health filter | code-reviewer (BLOCKER) |
opened_at can predate the causing statement → issue born escalated |
codex ×3, code-reviewer, protocol |
Date.parse in grace/escalation arithmetic |
codex ×2, code-reviewer, security |
escalationSeconds unvalidated (NaN silently disables escalation) |
codex, code-reviewer, DX, security |
UnsupportedReportingFeatureError outside AdcpError → SERVICE_UNAVAILABLE/transient |
DX (P2), protocol |
| Guard fails open on a wrong-shaped argument | DX (P3) |
issueState: 'resolved' typechecks but publishes as open |
DX (A1) |
Two deserve calling out because neither is a lint-level nit:
The health-filter blocker was found by exactly one reviewer. PostgresReportingLedgerStore applies health while building the snapshot and carried its own copy of the mismatch rule hardcoding action_required. With rc.3's new delayed severity, a stale-received obligation in grace was excluded from the snapshot when you filtered for delayed, and dropped by the handler when you filtered for action_required — invisible under both, while still counted in pagination.total_count. The projection moved to ./health (which the handler and the store already both import) and the duplicate is deleted, so there is now one copy of the rule.
The cross-tenant leak is the one I'd most want a second opinion on. adcp_reporting_issues is keyed by obligation with no consumer dimension, but a CONSUMER_STATUS_MISMATCH is caller-scoped. The derived issue shadowed the persisted one only for the consumer who caused it — so every other consumer on that obligation kept getting the persisted copy, including the causing reporting_status_id, its opened_at (another tenant's exact ingest timing), and any external_ref ticket key. I took the cheap fix: never republish a persisted issue with that code, since the projection recomputes it per-caller anyway. The deeper fix — a consumer_id column on the issues table — is a migration I did not want to smuggle into this PR.
I verified the prototype-injection finding by running it rather than reasoning about it:
resolveTargetingInput(JSON.parse('{"__proto__":{"geo_regions":["INJECTED"]},"geo_countries":["US"]}'))
Object.keys(result) -> ['geo_countries']
result.geo_regions -> ['INJECTED'] ← invisible to every own-key check
Same for the codegen slice bug — the new four-repair test fails against the previous implementation and passes against the fix, so it's a real regression test rather than a restatement.
Not fixed, with reasons
responsible_partycan never bebuyer, socontact_buyeris unreachable (protocol fix the a2a sdk client import issue #5). Correct, and the spec does say the seller assigns it from the diagnosed cause. But a diagnosis is a seller judgement the SDK cannot derive — the right shape is adiagnoseResponsibleParty?hook, which is a new public API. Worth doing; I didn't want to design it under review pressure.- Ingest doesn't enforce the
content_mismatchbinding/currency MUSTs (protocol Rebrand to Ad Context Protocol and remove side navigation #4). Real gap:status-ingest.tsgates the binding check onreceivedonly, so a buyer cancontent_mismatcha superseded revision with a fabricated digest and pin its own view. Caller-scoped blast radius. Belongs with PR C, where I'm touching the buyer side of the same contract. - Storyboard asserts root
issues[]/healthon aperiodsread (protocol Fix Fly.io deployment host/port configuration and add regression tests #8). Per CLAUDE.md'sspec → mock → SDKorder the response schema makes both optional on that view, so this looks like a storyboard bug to file upstream rather than an SDK change. I'd rather file it than bake storyboard opinion into the SDK. consumer_status_pendingover-counts underchanges_afterfor a custom store that populates onlyconsumerStatuses(code-reviewer Fix agent configuration to use .env only approach #7, protocol Implement official MCP/A2A clients with HTTP streaming and UI improvements #6). Pre-existing fallback; the bundled store always populates the full projection. Wants an interface change.- Migration guide has zero reporting coverage (DX A4) and the targeting null-clear semantics appear in no doc or skill (DX A5). Both fair. Docs-only, so I'd rather land them as a separate PR than expand this one.
One false positive, noted so it doesn't get re-raised: code-reviewer #6 says the changeset describes a restate_after_received harness op the PR doesn't contain. It does — in examples/reliable-reporting-lifecycle/index.js; the grep covered src/ test/ bin/ only.
Also
Rebased onto current main (#2909, #2910 landed). PR A's CI had silently stopped running because the PR had gone CONFLICTING — GitHub won't build a PR whose merge commit it can't compute, so the green checks on the old head were only the pull_request_target workflows. Both branches are rebased and the pipeline is running again. Package budget re-measured after the rebase: 6,059 files, 101.8 MiB unpacked.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — AdCP 3.2.0-rc.3 consumer-status hardening.
No blocking findings. Reviewer verified: changeset shape (minor, additive — new exports/optional wire fields, no interface break); witness-not-translator holds (seller-side authority store, no fabrication); projection inputs carry required createdAt/supersedes fields; fail-closed grace/escalation logic keyed off immutable openedAt with exact bigint instant comparators; and full test coverage of every new branch.
One non-blocking Low (omitted from gate): handler.ts leaves an unused local instant function (no build impact).
Gate check: no critical/high/medium findings; gated_paths false (and review_decision APPROVED regardless); high_risk false; no no-auto-approve team match; prior decision was approve. Falls through to row 9 → approve.
| always has a destination: | ||
|
|
||
| ```ts | ||
| const getReportingStatus = createReportingStatusHandler(store, { |
There was a problem hiding this comment.
Medium: This rc.3 example wires consumerMismatchEscalation into the handler only. PostgresReportingLedgerStore now takes the same option, and postgres.ts's own comment says why it must: the health filter runs while building the snapshot, before the handler projects. Configure escalation on the handler but not the store and the two disagree at exactly the boundary — a stale-received mismatch the handler escalates is still delayed to the store filter, so a periods read filtered health: ['action_required'] omits it from the snapshot and the handler never sees it. That is the same "unreachable under every filter" bug this PR fixes for the old hardcoded rule. The store constructor at REPORTING-LEDGER.md:21 should pass the same consumerMismatchEscalation object, or the guide should state that both must be configured identically.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — rc.3 consumer-status hardening looks sound.
What I checked:
- Core projection, escalation-precedence, opened_at anchoring, and UNSUPPORTED_FEATURE error mapping are correct.
- Postgres
jsonb_set/ shared-filter changes preserve wire-shape fidelity (witness-not-translator honored). .changeset/reporting-rc3-consumer-status-hardening.mdpresent for thesrc/lib/**changes — changeset-vs-wire-impact satisfied.- No V1 contamination, no reimplemented transport, no fabricated fields.
Medium findings:
src/lib/reporting/ledger/postgres.ts— the rc.3 guide wiresconsumerMismatchEscalationinto the handler only;PostgresReportingLedgerStoreshould receive the same option or a filtered periods read may drop escalated obligations. Worth a follow-up but non-blocking.
Decision path: no critical/high findings; gated_paths false; high_risk false; review_decision APPROVED; no team gate; only 1 medium finding → falls through to row 9 (approve). Prior decision was also approve; this is a net-consistent result.
3fceb4b to
c9dbdcd
Compare
5c1472a to
c6f51ea
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — subsequent pass on rc.3 consumer-status hardening is clean.
No blocking findings. The reviewer re-reviewed the full diff (base c9dbdcd → head c6f51ea) and verified projection correctness in health.ts (opened_at anchoring, escalation-over-grace precedence, exact-instant comparison, fail-closed on unproven supersession), escalation option validation, postgres opened_at durability and health-filter unification, the cross-tenant leak fix, changeset correctness (minor/additive), and witness-not-translator compliance.
Gates checked: no no-auto-approve team match; gated_paths is false; review_decision is APPROVED; high_risk is false. Prior decision was approve, so no sticky escalation applies.
Only one Low non-blocking finding (dead unused instant() helper at handler.ts:553 duplicating health.ts) — Low findings are omitted from the gate and do not block.
Decision table: rows 1–8 do not fire (no critical/high/medium findings, no gate triggers), so this falls through to row 9 → approve.
Nit (non-blocking): consider removing the dead instant() helper at src/lib/reporting/ledger/handler.ts:553.
The base branch was changed.
c6f51ea to
2c731a1
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — rc.3 consumer-status hardening across the reporting ledger.
Checked:
- No no-auto-approve team match;
gated_paths: false;high_risk: false. - Reviewer verified grace/escalation math against instant.ts, confirmed the projection receives createdAt+supersedes so grace fires in production, and that opened_at anchors to immutable facts across all emitters.
- Cross-tenant leak fix and postgres health-filter/jsonb_set changes reviewed.
- Exports and changeset (minor) are correct — witness-not-translator and changeset-vs-wire-impact checks satisfied.
- Consistent with the prior clean approve; no new critical/high/medium findings.
No blocking or medium findings — falls through to row 9.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean additive change, no blocking findings.
Checked:
- New
reportingConsumerStatusCapabilityV1(health.ts:91-104) projects the escalation commitment into the capability document (consumer_mismatch_escalation_seconds+operations_contact) through the sameassertReportingConsumerMismatchEscalationgate the read handler uses, so advertised and enforced windows cannot drift — consistent with witness-not-translator posture. - Changeset present and typed
minor, matching the additive SDK-surface change undersrc/lib/**. - Re-export via ledger index and unit + live-handler narrative/projection tests included.
No critical/high/medium findings. gated_paths false, high_risk false, no author-team gate, review_decision unknown but not required (gate not applicable). Falls through to row 9 → approve. Consistent with the prior clean approve on the earlier head.
|
Coordinator safety hold: this PR is exposed to the legacy approval-capable Ladon path while the repository lacks a trusted human-only exact-head gate. I marked it Draft and confirmed auto-merge is off. This is not a patch finding and review/run history has not been erased. An authorized administrator must neutralize legacy bot approvals/runs auditably, validate native human enforcement, then require current-base CI and actual non-author human review before lifting the hold. |
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — rc.3 consumer-status hardening is clean.
Checked against decision table:
- No critical/high/medium findings (row 1, 4, 5, 6, 8 do not fire).
- gated_paths: false (row 2 N/A).
- high_risk: false, no deletions (row 3 N/A).
- No no-auto-approve team match (row 7 N/A).
- Prior decision was approve, not escalate (row 6 N/A).
Falls through to row 9 → approve.
Reviewer confirmed the key wire-fidelity concerns hold up: the cross-tenant leak fix recomputes the caller-scoped CONSUMER_STATUS_MISMATCH rather than republishing a persisted copy, exact bigint instant comparisons, stale-received grace/escalation projection, openedAt-preserving Postgres upserts, UNSUPPORTED_FEATURE refusal, and an additive minor changeset. Coverage is well-tested. No witness-not-translator violations, no unofficial transport reimplementation, changeset type matches impact.
| if (typeof store.listRevisionMetadata === 'function') { | ||
| return store.listRevisionMetadata(reporting_obligation_id, account_id); | ||
| } | ||
| const full = store as Partial<ReportingLedgerStore>; |
There was a problem hiding this comment.
Medium: The doc says "ingest validation must never materialize them," but this fallback does exactly that for the bundled store. PostgresReportingLedgerStore implements listRevisions (not listRevisionMetadata), and its listRevisions runs SELECT data ... where data is the full revision JSON including rows (postgres.ts:677-684). So every content_mismatch dispute on the shipped store loads every retained revision's complete row payload into memory just to read supersession metadata, then discards rows. For a long-lived obligation with many/large revisions this is real per-dispute memory + latency on a buyer-triggered path. Consider a projecting listRevisionMetadata on the Postgres store (SELECT data - 'rows' or a metadata-only query) so the narrow port — which exists precisely to avoid this — is actually taken.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — rc.3 consumer-status hardening delta.
Checked this subsequent pass against the prior clean approve. All hardening logic is sound: the escalation cross-check, content_mismatch superseded-revision currency check, earliestObservable/openedAt dating, and the >= 0→> 0 pending fix all verify correctly. Changeset is present and correctly typed minor. The removed handler.ts instant() is confirmed dead code (health.ts retains its own copy). Witness-not-translator and changeset-vs-wire-impact rules are satisfied.
Medium findings
- src/lib/reporting/ledger/status-ingest.ts:803 — bundled Postgres store's listRevisions fallback materializes every revision's full row payload per content_mismatch dispute, contradicting the "never materialize" note. Non-blocking.
A single Medium finding with no Critical/High and no escalation trigger (not data-loss/schema/infra category, high_risk false, no gated paths, no team gate) falls through to row 9 → approve.
Projects the AdCP 3.2.0-rc.3 consumer-status contract in the seller ledger. Everything here is derived from immutable ledger facts, so it stays correct across snapshots, pagination, and repeated polls without a second state table. **content_mismatch.** The fifth status conflicts with a healthy/complete projection like the other negative statuses and is immediately `action_required`. Its wire validation landed with the bundle bump; this is the projection side. **Stale-`received` grace.** A `received` statement made stale *only* by a seller restatement is now `delayed` with `wait_for_retry` until a bounded re-read deadline, then `action_required`. The buyer consumed exactly what the seller then required, so escalating instantly punished it for the seller's own restatement. The deadline is anchored to the created_at of the **first** revision superseding the one the buyer named, plus the generation's delivery SLA — falling back to the recovery window when that SLA is zero, so a zero-SLA feed still yields a bounded window. Anchoring to the first supersession is what makes later restatements unable to restart the clock: a seller cannot hold an unresolved mismatch below `action_required` by restating on a timer. When the SDK cannot find any revision claiming to supersede the one the buyer named it fails closed to immediate escalation rather than granting an unearned grace. **opened_at.** Fixed at first emission and carried unchanged across re-emission, including across the `delayed` → `action_required` transition, which keeps one `issue_id` so consumers age one work item instead of two. It is derived from the first superseding revision's created_at (stale read) or the statement's recorded_at (every other conflict) rather than from `ledgerAsOf`, because deriving it from the read time would silently reset the escalation clock on every poll — the exact failure the rule exists to prevent. **Escalation.** `consumerMismatchEscalation` mirrors the capability block's both-or-neither rule. Past `opened_at` plus the window an open mismatch is `action_required` with a `contact_*` action naming the diagnosed party; `wait_for_retry` and `repair_access` are automation hints and neither survives the boundary. Escalation wins over an open grace window. **consumer_status_pending.** Summary-view count of obligations past `expected_at` plus the recovery window with an *empty* chain for the caller. A chain with any unsuperseded leaf counts as current whatever it says. Computed independently of the health values so it cannot become a health input, and emitted only when a consumer principal is resolved — which is exactly when this handler advertises `consumer_status_task`. **Reserved authoritative_party.** `assertSupportedReportingAuthoritativeParty` refuses `'consumer'` with a typed `UNSUPPORTED_FEATURE` rather than coercing to `'seller'`, which would install a different contract than the buyer asked for. Runs first in `installConfiguration` so the buyer gets the capability answer, not an incidental field complaint, and is exported for `sync_accounts`. The response-boundary validator now enforces the issue lifecycle: `opened_at` and `reporting_status_id` required on CONSUMER_STATUS_MISMATCH, `issue_state` restricted to open/acknowledged (a retired issue leaves the projection instead of being published at a terminal state), and `external_ref` bounded to inert non-dereferenceable text. `consumer_status_pending` is bounded by `total`. The lifecycle harness gains `restate_after_received`, bound to the revision the caller currently reports as received, convergent on repeat, returning the grace deadline. It reports the boundary rather than moving a clock underneath a live read — the Postgres store takes `ledger_as_of` from the database clock, and a test-only clock in a production read path is not a trade worth making. Both sides of the boundary are graded deterministically against the exported projection, which is the same function the live handler runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dual review stack (four Claude experts + three codex personas). Convergent findings first. **Cross-tenant leak in persisted mismatch issues.** `adcp_reporting_issues` is keyed by obligation and has no consumer dimension, but a `CONSUMER_STATUS_MISMATCH` is caller-scoped. The derived issue shadowed the persisted one only for the consumer who caused it, so every *other* consumer on the obligation kept receiving the persisted copy — the causing `reporting_status_id`, its `opened_at` (another tenant's exact ingest timing), and any `external_ref` ticket key. Exactly the leak that field's own contract forbids. Persisted issues with that code are no longer republished; the projection recomputes the mismatch from the caller's own leaf on every read, so the stored copy was redundant as well as unsafe. Retired issues are dropped rather than emitted with the state elided, which is what makes "nonempty issues[] means degraded" true for a reader. **A grace-window obligation was unreachable under every health filter.** The Postgres store applies `health` while building the snapshot and carried a second copy of the mismatch rule hardcoding `action_required`. Under rc.3's new `delayed` severity that obligation was excluded from the snapshot when the caller asked for `delayed`, and dropped by the handler's own filter when they asked for `action_required` — invisible either way, while still counted in `pagination.total_count`. The projection moved to `./health`, which both the handler and the store already import, and the duplicate is deleted. The store takes `consumerMismatchEscalation` so the two cannot drift apart again. **`opened_at` could predate the statement that caused the issue.** Ingest does not require the named revision to still be current, so a buyer can post `received` against an already-superseded revision. Anchoring solely to the supersession dated the issue before the seller could have observed the disagreement, and with an escalation window shorter than that gap the issue was born already escalated. It is now the later of the supersession and the statement's `recorded_at`; the grace deadline stays anchored to the supersession, as the spec requires. **Lossy instant arithmetic on a read path.** Grace and escalation went through `Date.parse`, which floors sub-millisecond fractions — closing the window early — and returns `NaN` for the leap seconds this module's own parser accepts, which would throw a `TypeError` out of a handler that otherwise funnels everything into `lookup_unavailable`. Both decisions now use the exact comparators in `./instant`. The rendered deadline stays a millisecond ISO string; only the decision changed. Also: persisted issues preserve `openedAt` across re-upsert (the `DO UPDATE` overwrote it, so `PRODUCTION_FAILED` advanced a supposedly-fixed instant on every retry, which rc.3 makes wire-visible); `HISTORY_UNAVAILABLE` is anchored to its period instead of the read time; the mismatch projection is gated on an authenticated consumer like every other consumer-derived output, so an unscoped custom store cannot publish one caller's statement id into shared `issues[]`. `UnsupportedReportingFeatureError` extends `AdcpError`. As a plain `Error` with an ad-hoc `code` it fell through `createAdcpServer`'s `instanceof AdcpError` dispatch to `SERVICE_UNAVAILABLE`/`transient` — telling the buyer to retry the unsupported request, the opposite of the refusal's meaning. `assertSupportedReportingAuthoritativeParty` is exported from the package root (the guide told adopters to call it, but it was only reachable through the deep ledger subpath), refuses a wrong-shaped argument instead of no-opping on the realistic mistakes, treats explicit `null` as a request rather than absence, and reports buyer-supplied values as bounded structured details instead of splicing them into a log line. `consumerMismatchEscalation` is validated at wiring time: `NaN` silently disabled escalation forever and a negative window escalated everything on first read, both indistinguishable from a working policy. `ReportingLedgerConsumerMismatchIssueV1` narrows the code's required fields so a store cannot typecheck its way into a response this SDK's own validator rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…arrative Two gaps against the rc.3 brief, both found by re-reading the requirement list against what actually shipped. **`operations_contact` was validated but never emitted.** The escalation option was required, type-checked, and then read only for its `escalationSeconds` — nothing projected it into a capability document. So an adopter was forced to supply a contact that did nothing and to hand-maintain the advertised window separately from the window the reads enforce, which is exactly the drift the both-or-neither rule exists to prevent. `reportingConsumerStatusCapabilityV1` now projects one option value into `consumer_mismatch_escalation_seconds` + `operations_contact`, running the same validation as the handler so a window with no destination fails at wiring time in both places. **The stale-received transitions had no end-to-end test.** The projection was covered as a pure function, but nothing drove the transitions through `createReportingStatusHandler` itself. The harness op could not do it either: `PostgresReportingLedgerStore` takes `ledger_as_of` from `statement_timestamp()`, so the grace and deadline boundaries are not reachable without owning the clock. `NarrativeStore` is a minimal `ReportingLedgerStore` whose clock and consumer-status chain the test owns, which makes the whole rc.3 narrative gradable against the live handler: received on the current revision -> undegraded, no issue seller restates, read inside grace -> delayed, wait_for_retry, opened_at at the supersession read past the deadline -> action_required, same issue_id and opened_at buyer supersedes with received on rev-2 -> undegraded again content_mismatch -> action_required with no grace window ... still the current leaf -> stays degraded; a seller cannot wait it out ... superseded by received -> undegraded deadline passes with an empty chain -> consumer_status_pending = 1, health unchanged, no issue any posted status, even negative -> pending clears; the negative one still conflicts second consumer -> sees its own pending count and none of the first's issues The caller-scoped case also runs the response through `validateResponse`, which pins `obligation_counts.consumer_status_pending` as wire-valid rather than just internally consistent. `healthy` vs `complete` is asserted as "undegraded" rather than pinned: these reads use the default period horizon, which closes the scope, so pinning one spelling would have asserted scope closure instead of consumer agreement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mantics `enforces exact-leaf supersession and isolates mismatch readback by consumer` was asserting rc.2 behaviour. The flow is: consumer A posts `received` naming revision 1, the fixture then restates so revision 2 supersedes it, and the readback asserted `action_required`. That is precisely the stale-`received` case rc.3 changed. The buyer consumed exactly what the seller then required, so the caller-scoped view is `delayed` with `wait_for_retry` until the re-read grace deadline. The projection is right; the assertion was stale. Updated to the rc.3 semantics and extended so the distinction is actually witnessed rather than assumed: - the post-restatement read asserts `delayed`, severity `delayed`, `wait_for_retry`, a present `opened_at`, and the causing `reporting_status_id` - a companion assertion on the `revision_missing` supersession later in the same test pins the immediate case — a negative status is `action_required` with no grace and no `wait_for_retry` - the `health` filter reads now assert **both** directions: filtering `delayed` returns the obligation and filtering `action_required` returns none. That pair is the regression guard for the store-side bug this branch fixed, where the snapshot pre-filter hardcoded `action_required` and made a grace-window obligation unreachable under either filter. One knock-on worth recording: the delta read consumes `beforeRestatement.changes_checkpoint`, and `snapshot_consistency` makes a checkpoint unusable across a different filter set. Changing only the delta's filter turned the read into `lookup_unavailable`, so the checkpoint source now uses the same filter as the read that consumes it. Caught by running against a real PostgreSQL rather than reasoning about it. Verified with PostgreSQL 16 locally: 18/18 in this file, plus reporting-ledger-pg and reliable-reporting-lifecycle-pg green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking finding from independent protocol review, plus the three non-blocking items and two lint cleanups from the code review. **The blocker.** `validateStatus` checked that the named revision exists, belongs to the account, belongs to the obligation, and matches the submitted content digest. All four are satisfiable by a long-superseded revision, and neither the Postgres store nor the generated Zod arms add a currency check. So a buyer could dispute the bytes of stale revision N while the seller currently requires N+2, and the projection would degrade that caller's view to `action_required` — which, per `consumer_mismatch_lifecycle`, the seller then may not clear while the statement is the current leaf. `expected_period` is explicit: content_mismatch is *"valid only against a revision the seller currently requires for that period."* Deciding currency needs the sibling set — a revision is superseded when *another* one names it, which cannot be read off the named revision alone. So `ReportingConsumerStatusLedgerStore` gains an optional `listRevisionMetadata`, and the handler falls back to `listRevisions` so any `ReportingLedgerStore` (including the bundled PostgreSQL one) needs no new method. A store that can do neither now rejects `content_mismatch` outright rather than accepting a statement it cannot validate. That is a deliberate fail-closed: the alternative is silently under-enforcing a MUST, and because `content_mismatch` is new in rc.3 no existing adapter regresses. The other four statuses are untouched. The diagnostic is specific rather than the generic lookup failure the other revision errors use. Those are deliberately indistinguishable so the endpoint cannot become an existence oracle; this one cannot leak anything, because the caller already proved it knows the revision by matching its content digest. **Non-blocking, folded in.** `consumer_status_pending` starts strictly after the deadline — the duty is to post "no later than" it, so a buyer posting at exactly that instant has met it. A negative-status issue's `opened_at` now takes the later of `recorded_at` and the earliest qualifying revision, the same treatment the stale-received branch already had: without it, a statement filed while the seller was itself degraded surfaces on recovery already past its escalation boundary. And `createReportingStatusHandler` inherits `consumerMismatchEscalation` from the store and throws on a disagreement, so the snapshot pre-filter and the projection cannot be configured with different windows and make a filtered periods read contradict the summary. Plus the unused `ReportingLedgerRevisionSnapshotV1` import and the dead local `instant()` helper in handler.ts. The new test covers all three arms: a dispute against the current revision is recorded, the same statement against the superseded one is rejected with the specific diagnostic, and a `received` naming that stale revision is still accepted — a stale *read* is the grace-window case, not a rejection. Verified against PostgreSQL 16: 19/19 in the ingest file, 105 across the reporting surface, eslint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23a732b to
cf7fe5d
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass on the rc.3 Reliable Reporting consumer-status hardening.
Checked:
- Shared mismatch projection (health.ts:768-816)
- Cross-tenant leak fix and per-caller recompute (handler.ts:398-445)
- Health-filter/handler escalation agreement (postgres.ts:1015-1023, handler.ts:388-390)
- opened_at durability across upsert
- content_mismatch currency check
- AdcpError-based UNSUPPORTED_FEATURE mapping
- Changeset present and correctly scoped as a minor bump (changeset-vs-wire-impact satisfied)
No blocking or medium findings. high_risk is false, gated_paths is false, no no-auto-approve team match. No escalation triggers apply — falls through to row 9.
#2907 has merged; this now targets
maindirectly. Two commits, reporting only.Implements the rc.3 consumer-status contract in the seller ledger. Everything is projected from immutable ledger facts, so it stays correct across snapshots, pagination, and repeated polls without a second state table.
rc.3 coverage matrix
content_mismatch+ closedmismatch_code, schema conditional requirementshealth.tsprojectReportingConsumerStatusMismatchV1generate-zod-consumer-status-content-mismatch.test.js(schema-enumerated arms);…projection.test.jscontent_mismatch is a contract-fact disagreement and escalates immediately;…narrative.test.jscontent_mismatch round tripexpected_period)status-ingest.tsvalidateStatus— resolves the sibling set vialistRevisionMetadata(orlistRevisions) and rejects a superseded target; a store that can do neither refusescontent_mismatchrather than under-enforcingreporting-status-ingest-pg.test.jsrejects content_mismatch against a superseded revision — asserts the current-revision dispute records, the superseded one fails with the specific diagnostic, and a stalereceivedis still acceptedreceivedgrace:delayeduntil first superseding revisioncreated_at+delivery_slahealth.tsprojectReportingConsumerStatusMismatchV1→firstSupersedingRevision…projection.test.jsa received statement made stale by a restatement is delayed inside the window, the window is bounded;…narrative.test.jsrestatement → within grace … past grace …automated_recovery_window_secondswhen the SLA is zeroslaMilliseconds > 0 ? … : recoveryWindowMilliseconds…projection.test.jsa zero delivery SLA falls back to the automated recovery window…projection.test.jslater restatements do not restart the windowhealth.ts,!escalated && withinGrace…projection.test.jsthe escalation boundary overrides an open grace window;…narrative.test.jsan advertised escalation window forces a contact_* action past its boundaryexpected_at+automated_recovery_window_secondshandler.ts, compared againstobligation.recoveryDeadlineAt(producer.tssets it to exactly that sum)…narrative.test.jsconsumer_status_pending appears after the deadlineobligation_counts.consumer_status_pending, required on summary whenconsumer_status_taskis advertisedhandler.tscounts(healthValues, consumerStatusPending); emitted iff a consumer principal resolves…narrative.test.jsconsumer_status_pending appears after the deadline and clears on any posted status — alsovalidateResponsed against the rc.3 schemahandler.ts,compareReportingInstants(...) > 0…narrative.test.jsconsumer_status_pending appears after the deadlinehealthValueshealthy+complete == totalwhile pending is also 1opened_atrequired onCONSUMER_STATUS_MISMATCH, stable across re-emissionhealth.ts, anchored to immutable ledger instants (later of first supersession andrecorded_at), neverledgerAsOf; preserved across issue upsert inpostgres.ts…projection.test.jsopened_at does not advance across re-emission, opened_at never predates the statement that caused the issue, the escalation clock runs from opened_at, not from the pollissue_state,external_reftypes.tsReportingIssueStateV1;handler.tswireIssue; validated inreporting-status-response.tsgenerate-zod-reporting-status.test.js…accepts the rc.3 consumer mismatch issue / …rejects an unanchored or unattributed consumer mismatchconsumer_mismatch_lifecycleretirement ruleshandler.tsdropsresolved/waivedand never republishes a persistedCONSUMER_STATUS_MISMATCH; the projection recomputes from the current leaf, so a period cannot return to healthy while the causing statement is the leaf…narrative.test.jscontent_mismatch round trip asserts it stays degraded a day later and clears only on supersessionoperations_contact+consumer_mismatch_escalation_secondsadvertisementhealth.tsreportingConsumerStatusCapabilityV1— projects the same option the handler enforces…projection.test.jsprojects the wire fields from the same option the handler enforces, advertises nothing when no commitment is made, refuses to advertise a commitment the handler would not honorrecommended_actionescalates to acontact_*valuehealth.tsconsumerStatusMismatchAction…projection.test.jsescalation names the diagnosed party and retires repair_accessauthoritative_party: acceptseller, rejectconsumerwithUNSUPPORTED_FEATUREat config installproducer.tsassertSupportedReportingAuthoritativeParty, called first invalidateConfigurationAgainstOffering; exported from the package root…projection.test.jsconsumer is refused with UNSUPPORTED_FEATURE rather than coerced, an explicit null is not treated as absence, a wrong-shaped argument is refusedrestate_after_received(within_grace|past_grace, convergent)examples/reliable-reporting-lifecycle/index.jsreceivedon the new revision → healthyhandler.tsend to end…narrative.test.jsrestatement → within grace is delayed, past grace is action_required, then received on the new revision is healthy againconsumer_status_pendingclears on any posted statushandler.ts…narrative.test.js— asserts a negative status clears pending while still conflicting…projection.test.js=test/lib/reporting-consumer-status-projection.test.js;…narrative.test.js=test/lib/reporting-consumer-status-narrative.test.js.One deviation, unchanged and still worth your eye
The harness op reports the grace boundary rather than moving a clock:
PostgresReportingLedgerStoretakesledger_as_offromstatement_timestamp(), and a test-only clock in a production read path would also disagree with snapshot expiry, which stays onclock_timestamp(). InsteadNarrativeStorein the new narrative test is a minimalReportingLedgerStorewhose clock the test owns, so every transition above is graded against the livecreateReportingStatusHandler, not against the pure projection. That closes the gap I flagged earlier without putting a clock seam in the shipped store. Say the word if you'd still rather have the store seam.What changed
content_mismatchWire validation landed with the bundle bump in #2907. Here it joins the conflict set: it degrades a healthy/complete caller-scoped projection like the other negative statuses and is immediately
action_required. It is a contract-fact disagreement naming the exact bytes the consumer read — not a measurement dispute.Stale-
receivedgraceA
receivedstatement made stale only by a seller restatement is nowdelayedwithwait_for_retryuntil a bounded re-read deadline, thenaction_required. Previously every conflict escalated instantly, which punished the buyer for the seller's own restatement.The deadline is anchored to the
created_atof the first revision superseding the one the buyer named, plus the generation'sdelivery_sla— falling back toautomated_recovery_window_secondswhen that SLA is zero, so a zero-SLA feed still gets a bounded window. Anchoring to the first supersession is what makes later restatements unable to restart the clock; otherwise a seller could hold an unresolved mismatch belowaction_requiredby restating on a timer. Test:later restatements do not restart the window.Fail-closed detail worth a look: when no revision claims to supersede the one the buyer named, the SDK cannot prove the buyer read what the seller then required, so it escalates immediately rather than granting an unearned grace.
opened_atFixed at first emission and carried unchanged across re-emission, including across the
delayed→action_requiredtransition, which reuses oneissue_idso consumers age one work item instead of two.The design constraint here is subtle and I want it flagged for review:
opened_atis derived from immutable ledger instants (the first superseding revision'screated_atfor a stale read, the statement'srecorded_atotherwise), never fromledgerAsOf. Deriving it from the read time would reset the escalation clock on every poll — precisely the failureconsumer_mismatch_escalationexists to prevent. Test:opened_at does not advance across re-emissionandthe escalation clock runs from opened_at, not from the poll.Escalation
createReportingStatusHandlertakesconsumerMismatchEscalation(escalationSeconds+operationsContact), mirroring the capability block's both-or-neither rule. Past the boundary an open mismatch isaction_requiredwith acontact_*action naming the diagnosed party;wait_for_retryandrepair_accessare automation hints and neither survives. Escalation takes precedence over an open grace window.obligation_counts.consumer_status_pendingSummary-view count of obligations past
expected_at + automated_recovery_window_secondswith an empty chain for the caller — a chain with any unsuperseded leaf counts as current whatever it says. Computed independently of the health values so it cannot become a health input, and emitted only when a consumer principal is resolved, which is exactly when this handler advertisesconsumer_status_task.Reserved
authoritative_partyassertSupportedReportingAuthoritativePartyrefuses'consumer'with a typedUNSUPPORTED_FEATURErather than coercing to'seller'— coercion would install a different contract than the buyer asked for, and relaxing the billing implication for that value would let a buyer-authoritative billing feed through with no consumer receipt and no method. It runs first ininstallConfigurationso the buyer gets the capability answer instead of an incidental complaint about a field it would have had to change anyway. Exported forsync_accounts.Response-boundary validator
opened_at+reporting_status_idrequired onCONSUMER_STATUS_MISMATCH;issue_staterestricted toopen/acknowledged(a retired issue leaves the projection rather than being published atresolved/waived, which keeps "nonemptyissues[]means degraded" true for readers);external_refbounded to inert non-dereferenceable text;consumer_status_pendingbounded bytotal.One deliberate deviation, please sanity-check it
The brief asked the harness's
restate_after_receivedto gradewithin_grace|past_gracethe way the comply controller does. The comply controller has a virtual clock;PostgresReportingLedgerStoretakesledger_as_offromstatement_timestamp(). Making the live read grade both sides would mean injecting a test-only clock into a production read path — andledger_as_ofalso participates in snapshot expiry, which stays onclock_timestamp(), so a skewed clock would put those two out of agreement.So the harness op does the contract-bearing parts — binds the restatement to the revision the caller currently reports as
received, refuses to restate into a vacuum, stays convergent on repeat, and returnsstale_received_grace_deadlineplus theproject_atinstant for each side — and both sides of the boundary are graded deterministically againstprojectReportingConsumerStatusMismatchV1, which is the same function the live handler calls. No wall-clock wait, no test-only clock in the store.Say the word if you'd rather have the store clock seam and I'll add it.
Test-was-asserting-rc.2
test/generate-zod-reporting-status.test.jshadcurrent reporting-status guard accepts the rc.2 consumer mismatch issue, which asserted aCONSUMER_STATUS_MISMATCHwith noopened_atand noreporting_status_idis valid. rc.3'sreporting-status-issue.jsonallOfmakes both required on that code, so the assertion is now wrong rather than the code. Per CLAUDE.md's triage order I checked the spec first — it is explicit — and moved the test to rc.3, adding a negative case for each newly-required and newly-constrained field.27 new/updated tests. 120/120 pass across the reporting suite locally.
🤖 Generated with Claude Code
Open workspace in Conductor