Skip to content

feat: expose buyer_reason on the AdcpErrorInfo + wire projection - #2856

Merged
ohalushchak-exadel merged 2 commits into
mainfrom
ohalushchak/expose-buyer-reason-on-adcp-error-info
Sep 8, 2026
Merged

feat: expose buyer_reason on the AdcpErrorInfo + wire projection#2856
ohalushchak-exadel merged 2 commits into
mainfrom
ohalushchak/expose-buyer-reason-on-adcp-error-info

Conversation

@ohalushchak-exadel

Copy link
Copy Markdown
Collaborator

Why

AdCP 3.2 RC.1 adds two normative signals on the error envelope:

  • error.recovery (transient | correctable | terminal) — the wire-authoritative retry classification, required whenever buyer_reason is present.
  • error.buyer_reason: { code, message } — a buyer-safe classification of the failure. code is drawn from the standard enums/error-code.json vocabulary; message MUST omit vendor identifiers, ad-server type names, internal object names, internal IDs, and stack traces.

The generated Zod schemas already carry buyer_reason. The hand-written client surface — the reader (AdcpErrorInfo), the throw shape (AdcpError / AdcpStructuredError), the builder (adcpError()), the sync-throw projection, the two-layer dispatcher, the per-row wire normalizer (normalizeError()), and the retry policy — did not. This PR wires it through every layer so an adopter that populates buyer_reason at the seam actually reaches the buyer with it, and a buyer that reads AdcpErrorInfo sees a typed, buyer-safe classification instead of undefined.

What Changed

Reader:

  • AdcpErrorInfo.buyer_reason and ExtractedAdcpError.buyer_reason typed.
  • buildExtracted / extractAdcpErrorInfo / extractAdcpErrorFromMcp / extractAdcpErrorFromTransport forward it. Partial payloads (missing code or message, empty strings, non-object) are dropped rather than surfaced as half-typed values.

Producer / seller:

  • AdcpStructuredError.buyer_reason and AdcpError constructor option typed; toStructuredError() round-trips it (async task-completion path).
  • adcpError() (via AdcpErrorOptions) accepts and emits it; AdcpErrorPayload declares it.
  • projectThrownAdcpError (framework sync-throw projection) spreads err.buyer_reason.
  • Two-layer dispatcher preserves it in both directions: sanitizePayloadError's standard-code projection includes the key, and PAYLOAD_ERROR_FIELDS (envelope ↔ payload mirror) allowlists it so the two wire layers stay in lockstep.
  • NormalizedError / normalizeError() (public API used by adopters projecting per-row batch errors) carries it with the same strict validation.

Retry policy:

  • BuyerRetryPolicy overrides receive error.buyer_reason and can key retry decisions on error.buyer_reason?.code. Default policy unchanged — routing on buyer_reason is opt-in via override.

Constraints preserved:

  • IDEMPOTENCY_CONFLICT / IDEMPOTENCY_IN_FLIGHT envelope allowlists intentionally strip buyer_reason — those wire-shape-restricted codes never carry a buyer-actionable classification. Negative test locks this in.

Test plan

  • Extraction round-trips: dict, pydantic-equivalent object, duck-typed. Partial-payload drops (missing message, empty strings, non-object).
  • Structured envelope round-trip: adcpError()extractAdcpErrorFromMcp preserves buyer_reason.
  • Throw path round-trip: new AdcpError('X', { buyer_reason })__unstable__projectThrownAdcpErrorextractAdcpErrorFromMcp preserves it (locks in the load-bearing spread inside projectThrownAdcpError).
  • Async task-completion serialization: AdcpError.toStructuredError() carries the field.
  • Two-layer projection observable equivalent: envelope with buyer_reason on CREATIVE_REJECTED survives extract.
  • Negative: IDEMPOTENCY_CONFLICT allowlist strips buyer_reason.
  • Row normalizer (normalizeError / normalizeErrors): forward from plain object and from AdcpError instance; drop missing-message, empty strings, non-object; absent-when-input-doesn't-carry-it; array coverage.
  • Retry policy: override sees error.buyer_reason.code and can route on it; default policy is unaffected by the field's presence.
  • npm run test:node:fast green (123 / 123).
  • npm run typecheck clean.
  • npm run build:lib clean.

Adopts the AdCP 3.2 `error.buyer_reason` sub-object on the
client-facing error surfaces. Generated Zod schemas already carried
it; the hand-written client shape did not.

Reader side:
- `AdcpErrorInfo.buyer_reason` and `ExtractedAdcpError.buyer_reason`
  now carry the buyer-safe `{code, message}` when the producer
  populated it.
- `buildExtracted`, `extractAdcpErrorInfo`,
  `extractAdcpErrorFromMcp`, and `extractAdcpErrorFromTransport` all
  forward it. Partial payloads (missing `code` or `message`, empty
  strings, non-object values) are dropped rather than surfaced as
  half-typed values a caller might render to a buyer.

Producer / seller side:
- `AdcpStructuredError.buyer_reason` and `AdcpError` constructor
  option added so adopters can throw a coarse top-level code with a
  specific buyer-actionable classification. `toStructuredError()`
  round-trips it through the async task-completion path.
- `adcpError()` (via `AdcpErrorOptions`) accepts and emits
  `buyer_reason`; `AdcpErrorPayload` declares it.
- Framework sync-throw projection (`projectThrownAdcpError`) spreads
  `err.buyer_reason` to the envelope builder.
- Two-layer dispatcher path preserves `buyer_reason` in both
  directions: `sanitizePayloadError`'s standard-code projection
  includes the key, and `PAYLOAD_ERROR_FIELDS` (envelope ↔ payload
  mirror) allowlists it so the two wire layers stay in lockstep.
- `NormalizedError` / `normalizeError()` (public API used by
  adopters projecting per-row batch errors — `sync_creatives`,
  `sync_audiences`, `sync_accounts`, `report_usage`,
  `acquire_rights`) now carries `buyer_reason` too, with the same
  strict validation as the reader extractor.

Retry policy:
- `BuyerRetryPolicy` overrides receive `error.buyer_reason` on the
  `error` argument and can key retry decisions on
  `error.buyer_reason?.code`. Default policy is unchanged — routing
  on `buyer_reason` is opt-in via override. Docstring calls out the
  "overrides map is keyed by outer `error.code`" constraint.

Constraints preserved:
- `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_IN_FLIGHT` envelope
  allowlists intentionally strip `buyer_reason` — those
  wire-shape-restricted codes never carry a buyer-actionable
  classification.

Tests:
- New `test/normalize-errors.test.js` (7 cases) covering the row
  normalizer's forward + drop paths.
- Extended `test/error-extraction.test.js` with buyer_reason
  preservation across the L3 / L2 / transport paths, malformed
  drops, and full round-trips through `adcpError()`,
  `AdcpError.toStructuredError()`, and the sync-throw framework
  path via a new `__unstable__projectThrownAdcpError` export used
  only by tests.
- Extended `test/lib/buyer-retry-policy.test.js` with override
  visibility of `buyer_reason.code` and a backward-compat pin for
  the default policy.
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 7, 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 — additive AdCP 3.2 error.buyer_reason wiring.

Checked:

  • Wire-shape fidelity: the buyer_reason field wiring across the reader/extractor, producer/builder, row normalizer, two-layer dispatcher, and retry-policy surfaces matches the generated types and Zod ErrorSchema. Fail-closed, symmetric validation between the reader extractor and row normalizer; no fabrication or re-shaping at a seam (witness-not-translator preserved).
  • Restricted-code allowlists correctly strip the field.
  • Changeset-vs-wire-impact: the minor changeset accurately reflects the non-breaking additive impact of touching src/lib/**; changeset present and correctly typed.

No Critical/High/Medium findings. One Low observation (the two-layer projection test re-extracts rather than exercising PAYLOAD_ERROR_FIELDS directly) noted as a non-blocking follow-up.

Decision-table trace: no critical/high (row 1 n/a); gated_paths false (row 2 n/a); high_risk false (rows 3/5 n/a); no medium findings (rows 4/8 n/a); no prior escalation (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 — approve. Note: review_decision is REVIEW_REQUIRED, but since gated_paths is false, that does not block approval.

Formatting-only fix for the "Check code formatting" CI step —
`npx prettier --write` on the two test files added in the previous
commit. No behavior change.

@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.

Delta since the prior approve (head a8960a5) is pure Prettier reformatting of two test files — collapsed multiline literals and arrow-param paren style. No behavior change, no new code paths, and no library or wire surface touched. Changeset present (.changeset/expose-buyer-reason-on-adcp-error-info.md) covering the src/lib/** changes.

Decision-table walk (top-down):

  • No no-auto-approve team match.
  • gated_paths: false — row 2 does not apply despite review_decision REVIEW_REQUIRED.
  • high_risk: false — rows 3/5 do not apply.
  • No critical/high/medium findings — rows 1, 4, 8 do not apply.
  • Prior decision was approve, so sticky-escalation row 6 does not apply.
  • Falls through to row 9 → approve.

No blocking findings. No medium findings.

@ohalushchak-exadel
ohalushchak-exadel merged commit bd13402 into main Sep 8, 2026
37 checks passed
@ohalushchak-exadel
ohalushchak-exadel deleted the ohalushchak/expose-buyer-reason-on-adcp-error-info branch September 8, 2026 11:26
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