Skip to content

feat(reporting): rc.3 consumer-status hardening (seller ledger) - #2914

Merged
bokelley merged 5 commits into
mainfrom
feat/reporting-rc3-consumer-status-hardening
Sep 14, 2026
Merged

bokelley merged 5 commits into
mainfrom
feat/reporting-rc3-consumer-status-hardening

Conversation

@bokelley

@bokelley bokelley commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

#2907 has merged; this now targets main directly. 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

rc.3 requirement Implemented in Tested by
content_mismatch + closed mismatch_code, schema conditional requirements Bundle-derived Zod arms (#2911) activated by the rc.3 pin (#2907); conflict projection in health.ts projectReportingConsumerStatusMismatchV1 generate-zod-consumer-status-content-mismatch.test.js (schema-enumerated arms); …projection.test.js content_mismatch is a contract-fact disagreement and escalates immediately; …narrative.test.js content_mismatch round trip
… valid only against the currently required revision (expected_period) status-ingest.ts validateStatus — resolves the sibling set via listRevisionMetadata (or listRevisions) and rejects a superseded target; a store that can do neither refuses content_mismatch rather than under-enforcing reporting-status-ingest-pg.test.js rejects content_mismatch against a superseded revision — asserts the current-revision dispute records, the superseded one fails with the specific diagnostic, and a stale received is still accepted
Stale-received grace: delayed until first superseding revision created_at + delivery_sla health.ts projectReportingConsumerStatusMismatchV1 → firstSupersedingRevision …projection.test.js a received statement made stale by a restatement is delayed inside the window, the window is bounded; …narrative.test.js restatement → within grace … past grace …
Fallback to automated_recovery_window_seconds when the SLA is zero same, slaMilliseconds > 0 ? … : recoveryWindowMilliseconds …projection.test.js a zero delivery SLA falls back to the automated recovery window
Later restatements must not restart the window anchored to the first supersession of the revision the buyer named …projection.test.js later restatements do not restart the window
Escalation window takes precedence over grace health.ts, !escalated && withinGrace …projection.test.js the escalation boundary overrides an open grace window; …narrative.test.js an advertised escalation window forces a contact_* action past its boundary
Buyer posting deadline expected_at + automated_recovery_window_seconds handler.ts, compared against obligation.recoveryDeadlineAt (producer.ts sets it to exactly that sum) …narrative.test.js consumer_status_pending appears after the deadline
obligation_counts.consumer_status_pending, required on summary when consumer_status_task is advertised handler.ts counts(healthValues, consumerStatusPending); emitted iff a consumer principal resolves …narrative.test.js consumer_status_pending appears after the deadline and clears on any posted status — also validateResponsed against the rc.3 schema
… counted strictly after the deadline (the duty is "no later than") handler.ts, compareReportingInstants(...) > 0 …narrative.test.js consumer_status_pending appears after the deadline
…never a health input; overlaps rather than partitions computed independently of healthValues same test asserts health unchanged, no issue created, and healthy+complete == total while pending is also 1
opened_at required on CONSUMER_STATUS_MISMATCH, stable across re-emission health.ts, anchored to immutable ledger instants (later of first supersession and recorded_at), never ledgerAsOf; preserved across issue upsert in postgres.ts …projection.test.js opened_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 poll
issue_state, external_ref types.ts ReportingIssueStateV1; handler.ts wireIssue; validated in reporting-status-response.ts generate-zod-reporting-status.test.js …accepts the rc.3 consumer mismatch issue / …rejects an unanchored or unattributed consumer mismatch
consumer_mismatch_lifecycle retirement rules handler.ts drops resolved/waived and never republishes a persisted CONSUMER_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.js content_mismatch round trip asserts it stays degraded a day later and clears only on supersession
operations_contact + consumer_mismatch_escalation_seconds advertisement health.ts reportingConsumerStatusCapabilityV1 — projects the same option the handler enforces …projection.test.js projects 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 honor
recommended_action escalates to a contact_* value health.ts consumerStatusMismatchAction …projection.test.js escalation names the diagnosed party and retires repair_access
authoritative_party: accept seller, reject consumer with UNSUPPORTED_FEATURE at config install producer.ts assertSupportedReportingAuthoritativeParty, called first in validateConfigurationAgainstOffering; exported from the package root …projection.test.js consumer is refused with UNSUPPORTED_FEATURE rather than coerced, an explicit null is not treated as absence, a wrong-shaped argument is refused
Harness restate_after_received (within_grace | past_grace, convergent) examples/reliable-reporting-lifecycle/index.js see the deviation note below
Narrative: within_grace → delayed → past_grace → action_required → supersede with received on the new revision → healthy handler.ts end to end …narrative.test.js restatement → within grace is delayed, past grace is action_required, then received on the new revision is healthy again
consumer_status_pending clears on any posted status handler.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: PostgresReportingLedgerStore takes ledger_as_of from statement_timestamp(), and a test-only clock in a production read path would also disagree with snapshot expiry, which stays on clock_timestamp(). Instead NarrativeStore in the new narrative test is a minimal ReportingLedgerStore whose clock the test owns, so every transition above is graded against the live createReportingStatusHandler, 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_mismatch

Wire 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-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. Previously every conflict escalated instantly, which punished the buyer 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 automated_recovery_window_seconds when 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 below action_required by 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_at

Fixed at first emission and carried unchanged across re-emission, including across the delayed → action_required transition, which reuses one issue_id so consumers age one work item instead of two.

The design constraint here is subtle and I want it flagged for review: opened_at is derived from immutable ledger instants (the first superseding revision's created_at for a stale read, the statement's recorded_at otherwise), never from ledgerAsOf. Deriving it from the read time would reset the escalation clock on every poll — precisely the failure consumer_mismatch_escalation exists to prevent. Test: opened_at does not advance across re-emission and the escalation clock runs from opened_at, not from the poll.

Escalation

createReportingStatusHandler takes consumerMismatchEscalation (escalationSeconds + operationsContact), mirroring the capability block's both-or-neither rule. Past the boundary 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. Escalation takes precedence over an open grace window.

obligation_counts.consumer_status_pending

Summary-view count of obligations past expected_at + automated_recovery_window_seconds 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' — 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 in installConfiguration so the buyer gets the capability answer instead of an incidental complaint about a field it would have had to change anyway. Exported for sync_accounts.

Response-boundary validator

opened_at + reporting_status_id required on CONSUMER_STATUS_MISMATCH; issue_state restricted to open / acknowledged (a retired issue leaves the projection rather than being published at resolved / waived, which keeps "nonempty issues[] means degraded" true for readers); external_ref bounded to inert non-dereferenceable text; consumer_status_pending bounded by total.

One deliberate deviation, please sanity-check it

The brief asked the harness's restate_after_received to grade within_grace | past_grace the way the comply controller does. The comply controller has a virtual clock; PostgresReportingLedgerStore takes ledger_as_of from statement_timestamp(). Making the live read grade both sides would mean injecting a test-only clock into a production read path — and ledger_as_of also participates in snapshot expiry, which stays on clock_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 returns stale_received_grace_deadline plus the project_at instant for each side — and both sides of the boundary are graded deterministically against projectReportingConsumerStatusMismatchV1, 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.js had current reporting-status guard accepts the rc.2 consumer mismatch issue, which asserted a CONSUMER_STATUS_MISMATCH with no opened_at and no reporting_status_id is valid. rc.3's reporting-status-issue.json allOf makes 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

// 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 =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 new consumer_status_pending obligation count and its > total wire-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.

@bokelley
bokelley force-pushed the feat/reporting-rc3-consumer-status-hardening branch from f8efe98 to 2506b19 Compare September 14, 2026 16:40
@bokelley
bokelley force-pushed the feat/adopt-adcp-3-2-0-rc-3 branch from 219b945 to 3fceb4b Compare September 14, 2026 16:51
@bokelley
bokelley force-pushed the feat/reporting-rc3-consumer-status-hardening branch from 2506b19 to 5c1472a Compare September 14, 2026 16:55

Copy link
Copy Markdown
Contributor Author

Review stack results — 5c1472a9

Ran the full REVIEW-STACKS.md dual stack: four Claude experts (protocol, code, security, DX) in parallel plus review:codex --all.

Codex was initially blocked — its sandbox needs bwrap, absent from this VM, so it read no files and returned "review blocked" for all three personas. I installed bubblewrap; the kernel still refuses the namespace (Unexpected capabilities but not setuid). Re-ran with the diff piped via stdin, which is the documented fallback, and got three real passes. Flagging because a blocked codex run reads like a clean one if you only look at the exit code.

Convergent — fixed

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_party can never be buyer, so contact_buyer is 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 a diagnoseResponsibleParty? 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_mismatch binding/currency MUSTs (protocol Rebrand to Ad Context Protocol and remove side navigation #4). Real gap: status-ingest.ts gates the binding check on received only, so a buyer can content_mismatch a 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[]/health on a periods read (protocol Fix Fly.io deployment host/port configuration and add regression tests #8). Per CLAUDE.md's spec → mock → SDK order 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_pending over-counts under changes_after for a custom store that populates only consumerStatuses (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

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md present for the src/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 wires consumerMismatchEscalation into the handler only; PostgresReportingLedgerStore should 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.

@bokelley
bokelley force-pushed the feat/adopt-adcp-3-2-0-rc-3 branch from 3fceb4b to c9dbdcd Compare September 14, 2026 17:58
@bokelley
bokelley force-pushed the feat/reporting-rc3-consumer-status-hardening branch from 5c1472a to c6f51ea Compare September 14, 2026 18:02
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 14, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from feat/adopt-adcp-3-2-0-rc-3 to main September 14, 2026 18:37
@bokelley
bokelley dismissed aao-secretariat[bot]’s stale review September 14, 2026 18:37

The base branch was changed.

@bokelley
bokelley changed the base branch from feat/adopt-adcp-3-2-0-rc-3 to main September 14, 2026 18:37
@bokelley
bokelley force-pushed the feat/reporting-rc3-consumer-status-hardening branch from c6f51ea to 2c731a1 Compare September 14, 2026 18:48

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 14, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 same assertReportingConsumerMismatchEscalation gate 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 under src/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.

bokelley commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author

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.

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 14, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
bokelley and others added 4 commits September 14, 2026 20:35
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>
@bokelley
bokelley force-pushed the feat/reporting-rc3-consumer-status-hardening branch from 23a732b to cf7fe5d Compare September 14, 2026 20:43

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bokelley
bokelley merged commit 537f193 into main Sep 14, 2026
37 checks passed
@bokelley
bokelley deleted the feat/reporting-rc3-consumer-status-hardening branch September 14, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant