Skip to content

feat(protocol): adopt AdCP 3.2.0-rc.3 - #2907

Merged
bokelley merged 3 commits into
mainfrom
feat/adopt-adcp-3-2-0-rc-3
Sep 14, 2026
Merged

bokelley merged 3 commits into
mainfrom
feat/adopt-adcp-3-2-0-rc-3

Conversation

@bokelley

@bokelley bokelley commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

First PR in the Reliable Reporting rc.3 series. Mechanical bundle + pin adoption, plus the one place where the bundle swap is not mechanical.

What's in it

  • ADCP_VERSION / package.json#adcp_version / default wire pin → 3.2.0-rc.3, from the published v3.2.0-rc.3 release (tagged 18:11Z). Signed bundle verified — sha256 1cd940f76516b43327cbfa6c17c0befd4c093ca85b53a81a4062d38bb522d831, cosign signature verified against the adcontextprotocol/adcp release workflow identity.

    Re-verified after the tag went live, because I first synced at 14:20Z and a re-cut artifact would have invalidated every generated file here. That digest is byte-identical across all three sources — the live /protocol/3.2.0-rc.3.tgz, the asset attached to the release tag, and what I originally generated from — and a full sync-schemas re-run against the tagged release verifies the signature and produces zero drift in tracked files. No signature check was skipped or worked around at any point.

  • npm run sync-schemas -- 3.2.0-rc.3, generate-types, generate-wire-spec-fields, generate-registry-types --sync (no registry delta), generate-agent-docs → docs/llms.txt + docs/TYPE-SUMMARY.md.

  • Every rc.2-pinned test/script expectation moved to rc.3 the same way feat(protocol): adopt AdCP 3.2.0-rc.2 #2888 did for rc.2, plus doc pins in WIRE-VERSION-COMPAT.md, MEDIA-BUY-3.2-COMPATIBILITY.md, REPORTING-LEDGER.md, migration-12-to-14.md, migration-13-to-14.md.

The part that isn't mechanical

scripts/generate-zod-from-ts.ts carries a hand-written parity block that restores the context-sensitive sync_reporting_status constraints the JSON Schema → TS → Zod projection drops. It was written against rc.2. Swapping the bundle alone would have shipped a validator that rejects mismatch_code as an unrecognized key while accepting a content_mismatch statement with no code at all — precisely inverted. Fixed here:

statement rc.3 rule now enforced
content_mismatch requires obligation id, revision id, observed_revision_content_sha256, mismatch_code; forbids failure_code
received / obligation_missing / revision_missing / unreadable additionally forbid mismatch_code

test/generate-zod-consumer-status.test.js gains six cases. That test is differential — it compiles the published JSON Schema with Ajv and the generated Zod with tsx, then asserts both agree — so it pins the generated validator against the signed schema rather than against my reading of it.

ReportingLedgerConsumerStatementV1 gains content_mismatch + the closed mismatch_code. Projection semantics are deliberately not here — severity, the stale-received grace window, escalation, and obligation_counts.consumer_status_pending land in PR B.

Other rc.3 changesets, audited for runtime impact

  • add-media-buy-frequency-cap-scope (#7449) / add-scoped-frequency-cap-support (#7438) — no runtime change needed. rc.3 is the first bundle to publish core/media-buy-available-action-id.json, so codegen now emits STRUCTURED_ONLY_MEDIA_BUY_ACTIONS = ["update_media_buy_frequency_cap"] and the frequency_cap → update_media_buy_frequency_cap binding in ACTIONS_BY_FIELD. src/lib/media-buy/preflight.ts:218 already resolved that action from the generated table instead of hardcoding it (fix(media-buy): merge structured-only action metadata into update-field dispatch #2889), so the shared cap becomes dispatchable with zero adopter change. overlay_support.frequency_cap_support and the constraint-aware discovery objects reach buyers through existing passthrough typing (required_overlay_support is forwarded as-is in negotiation/buyer.ts and media-buy/compatibility.ts), so constraint-aware discovery needs no new code either.
  • targeting-input-null-clear-semantics (#7466) — needs a runtime change, and it is not in this PR. PackageRequest/PackageUpdate.targeting_overlay now resolve to TargetingOverlayInput, whose dimensions are individually nullable. createMediaBuyStore's mergeOverlay already implements DR-0020's three states correctly at runtime (omit → preserve, null → clear, value → replace), but two gaps open up once nulls can actually arrive on the wire: the store's input types are still the strict readback TargetingOverlay, and persistFromCreate / the new_packages arm of mergeFromUpdate assign the incoming overlay without stripping null dimensions, so a null command can land in durable state and then be echoed by backfill into a get_media_buys response whose schema forbids null. Split into PR D to keep this PR a reviewable bundle swap.

rc.1 / rc.2 retention — measured, and it does not fit

The ask was to keep rc.2 and rc.1 selectable for pinned peers. I implemented the measurement rather than the change, because the two constraints in the brief collide:

  • One shipped 3.2 bundle is 36 MB / ~1,050 published files in dist/lib/schemas-data/. Retaining rc.1 + rc.2 adds ~72 MB and ~2,100 files, against enforced limits of MAX_UNPACKED_PACKAGE_BYTES = 120 MB (dist is already 127 MB pre-filter) and MAX_PACKED_FILE_COUNT = 5,995. Both break.
  • The cheap version — adding the aliases to COMPATIBLE_ADCP_VERSIONS without shipping bundles — is a trap, not a feature. resolveBundleKey returns a prerelease pin verbatim and resolveSchemaRoot throws when the directory is absent; no silent fallback. Verified against this build:
3.2.0-rc.2 => THREW: AdCP schema data for version "3.2.0-rc.2" not found
3.2-rc.2   => THREW: AdCP schema data for version "3.2-rc.2" not found
3.2-rc     => resolved (newest cached rc → rc.3)

So I kept the repo's existing exact-pin policy (what sync-version.ts does by default, and what rc.2 did to rc.1) and documented the one zero-cost affordance that does work: adcpVersion: '3.2-rc' fuzzy-matches the newest cached 3.2.0-rc.* bundle, analogous to 3.1-beta. migration-13-to-14.md now says this explicitly, including that a superseded exact pin errors at schema load rather than silently validating against a different contract.

Your call on the follow-up, if you want more than that: (i) raise the size/file budgets and ship rc.2 only (+36 MB), or (ii) add a validation-only compact side bundle for prior 3.2 rcs — much smaller than a full bundle, but new machinery. Say which and I'll add it to the stack.

🤖 Generated with Claude Code


Open workspace in Conductor

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 mechanical adoption of AdCP 3.2.0-rc.3.

This PR pins the ADCP version, adds a correct minor changeset, and regenerates the generated type files. The one hand-written change (the content_mismatch/mismatch_code parity block in generate-zod-from-ts.ts) is correct, matches the changeset, and is covered by the differential Ajv-vs-Zod test with six new cases. No library version was hand-edited in package.json, and the changeset type accurately reflects impact.

No blocking findings and no Medium findings were reported.

Decision-table walk-through:

  • Row 1 (critical/high): no such findings.
  • Row 2 (gated_paths + not APPROVED): gated_paths is false — does not fire.
  • Rows 3–5 (high_risk deletions/schema/infra mediums): high_risk is false, no mediums.
  • Row 6 (sticky escalate): no prior decision.
  • Row 7 (no-auto-approve team): no team match.
  • Row 8 (≥3 mediums): zero mediums.
  • Row 9: approve.

Note: review_decision is REVIEW_REQUIRED, but since gated_paths is false, row 2 does not apply and the deprecated protected-branch rule no longer forces a comment. Clean diff falls through to approve.

Copy link
Copy Markdown
Contributor Author

Pushed 7f7493f8 addressing the triage, plus two things the triage did not cover.

1. Package budget — raised 5,995 → 6,025 with itemization. rc.3 publishes 16 new source schemas (scoped + media-buy frequency-cap families, core/media-buy-available-action-id.json, and the shared frequency-cap duration/control/mutable-field enums) and 2 new compliance storyboards; with bundled and compact-projection copies that is +26. The new Targeting Input helper module adds +4. Measured after the change: 18.5 MiB packed, 101.7 MiB unpacked, 6025 files.

2. Version-negotiation fixtures — fixed, but the cause is worth recording because it is a trap for the next bump too. These are fail-closed tests whose premise is that the seller advertises or serves something newer than the buyer pin. The blanket rc.2 → rc.3 sweep rewrote the buyer pin as well, so "newer" became "equal" and the assertions inverted. Rather than re-pin them to rc.3, both sides now derive from the SDK's own constant:

const SDK_PIN = ADCP_VERSION;
const ONE_BEYOND_SDK_PIN = /* rc.N -> rc.N+1, or X.Y.Z -> X.(Y+1).0 */;

Three tests converted (does not select a newer prerelease than the compact buyer pin, fails closed when every valid advertised version is newer than the buyer pin, fails closed when an exact newer prerelease is served despite an older advertised fallback), plus the capabilities() default. 284/284 pass in that file.

3. Targeting Input codegen bug (not in the triage). Typechecking the rc.3 bundle against the existing surface surfaced a real defect in TargetingOverlayInput:

device_platform?: DevicePlatform | null;   // scalar enum — WRONG
device_type?: DeviceType | null;           // scalar enum — WRONG

The wire carries arrays in both cases. core/targeting-input.json reaches each dimension via a JSON-pointer $ref into core/targeting.json#/properties/<dimension>, and where that array has no title of its own, json-schema-to-typescript names the result after the items' canonical $ref. I confirmed the spec is right and the SDK is wrong (per CLAUDE.md's spec → mock → SDK triage order) by compiling targeting-input.json standalone through the same jsts version — that produces the correct [DevicePlatform, ...DevicePlatform[]] | null, so the loss is specific to the aggregate pass.

alignTargetingInputArrayCardinality repairs it from the invariant the two schemas actually satisfy — identical 37-property sets, so Input is Overlay plus | null per dimension — and fires only where the Input side is provably the item type of the Overlay side's array. Correct array aliases (DevicePlatformExclude, Browser, GeoCountries, …) are untouched, and per-property JSDoc survives because only the annotation is replaced. It logs what it repaired:

🎯 Restored 2 TargetingOverlayInput array dimension(s): device_platform: DevicePlatform -> [DevicePlatform, ...DevicePlatform[]], device_type: DeviceType -> [DeviceType, ...DeviceType[]]

4. Null-clear durability (#7466 / DR-0020) folded in, so no PR D. The codegen bug made this non-optional here — the strict/loose split is unusable while one side is the wrong shape, and the Example Typechecks failure was the split landing on examples/seller-3.2-starter.ts. New resolveTargetingInput / applyTargetingInput / hasTargetingClears (root + @adcp/sdk/server), and two real bugs closed in createMediaBuyStore: persistFromCreate fell back to the request overlay verbatim when the seller echoed none, and the new_packages arm of mergeFromUpdate assigned the incoming overlay directly. Either path wrote a null clear command into durable state, which backfill would then echo into a get_media_buys response whose schema forbids null. 10 new tests.

On the tag: sync-schemas verified the cosign signature cleanly at 14:20Z — sha256 1cd940f76516…, identity adcontextprotocol/adcp release workflow — so nothing in this PR rests on an unverified artifact.

Moving to PR B.

@aao-secretariat

Copy link
Copy Markdown

Ladon cannot review this PR until merge conflicts are resolved.

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 — AdCP 3.2.0-rc.3 adoption, clean pass.

Applied the decision table top-down:

  • Row 1 (critical/high): no such findings.
  • Row 2 (gated_paths + not APPROVED): gated_paths is false, does not fire.
  • Row 3 (high_risk deleted): high_risk is false, does not fire.
  • Rows 4–5 (medium data-loss/schema/infra or high_risk modified + medium): no medium findings.
  • Row 6 (sticky escalate): prior decision was approve, not escalate.
  • Row 7 (no-auto-approve team): no team match.
  • Row 8 (≥3 medium): zero medium findings.
  • Row 9: approve.

Reviewer reported a clean pass. Checked the delta's runtime changes: the new null-clear projection in targeting-input.ts (prototype-pollution defended, correct three-state semantics), its use in media-buy-store.ts (persist/new_packages leak paths closed, whole-clear preserved), the generate-types.ts cardinality-restore codegen, and the zod content_mismatch parity block. Two changesets are present and correctly typed for the additive exports and widened store input types — changeset-vs-wire-impact obligation satisfied. No witness-not-translator or transport-client concerns. This is a net-neutral subsequent pass over the prior clean approve.

Note: review_decision is REVIEW_REQUIRED, but with gated_paths false this does not block auto-approval per row 2.

bokelley and others added 3 commits September 14, 2026 17:50
Bump ADCP_VERSION and the default wire pin to 3.2.0-rc.3, resync the signed
schema and compliance bundles, and regenerate every derived surface
(core/tools/enums/inline-enums/manifest/schemas, wire-spec fields,
entity hydration, media-buy update fields, agent docs, TYPE-SUMMARY).

The bundle swap is not purely mechanical in one place. The hand-written Zod
parity block in scripts/generate-zod-from-ts.ts encodes the context-sensitive
consumer-status constraints that the JSON Schema -> TS -> Zod projection drops,
and it was written against rc.2. Left alone, the rc.3 pin would have produced a
validator that rejects `mismatch_code` as an unrecognized key while accepting a
`content_mismatch` statement with no code at all — exactly backwards. The block
now covers the rc.3 arm: `content_mismatch` requires the obligation id, the
revision id, the recomputed `observed_revision_content_sha256`, and
`mismatch_code`, and forbids `failure_code`; the four pre-existing statuses
additionally forbid `mismatch_code`. The differential test in
test/generate-zod-consumer-status.test.js gains six cases pinning generated-Zod
against the published schema for that arm.

`ReportingLedgerConsumerStatementV1` gains `content_mismatch` and the closed
`mismatch_code` so the ledger types can carry what the wire now accepts. The
projection semantics (severity, grace window, escalation, obligation counts)
are deliberately not in this commit.

rc.3 also publishes core/media-buy-available-action-id.json, so the generated
update dispatch table gains the structured-only
`update_media_buy_frequency_cap` action and its `frequency_cap` binding.
Media-buy preflight already resolved that binding from the generated table
rather than hardcoding it, so the shared media-buy frequency cap becomes
dispatchable with no adopter change.

3.2 prereleases stay exact pins: rc.3 replaces rc.2 in
COMPATIBLE_ADCP_VERSIONS and only the current prerelease's bundle ships.
`adcpVersion: '3.2-rc'` follows whichever release candidate a build carries;
a superseded exact pin raises a configuration error at schema load rather
than silently validating against a different contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things, all consequences of the rc.3 bundle meeting the existing surface.

**Package budget.** rc.3 publishes sixteen new source schemas (the scoped and
media-buy frequency-cap families, `core/media-buy-available-action-id.json`,
and the shared frequency-cap duration/control/mutable-field enums) plus two
new compliance storyboards. With bundled and compact-projection copies that is
+26 published files; the Targeting Input helper module adds +4 more. Budget
moves 5,995 -> 6,025 with the itemization in the comment. Unpacked is 101.7 MiB
against a 120 MiB ceiling.

**Version-negotiation fixtures.** The blanket rc.2 -> rc.3 pin sweep inverted
three fail-closed tests: their whole point is that the seller advertises or
serves something *newer* than the buyer pin, and rewriting the buyer pin to
match made "newer" equal. They now derive both sides from `ADCP_VERSION` and a
`ONE_BEYOND_SDK_PIN` computed from it, so the relationship survives the next
bump instead of silently degrading into "seller advertises the same version".

**Targeting Input codegen.** `TargetingOverlayInput.device_platform` and
`.device_type` were emitted as the scalar `DevicePlatform` / `DeviceType`
enums where the wire carries arrays. `core/targeting-input.json` reaches those
dimensions through a JSON-pointer `$ref` into
`core/targeting.json#/properties/<dimension>`, and for an array with no
`title` of its own json-schema-to-typescript names the result after the items'
canonical `$ref`. Verified against a standalone jsts compile, which produces
the correct tuple — the loss is specific to the aggregate pass.
`alignTargetingInputArrayCardinality` repairs it from the invariant the two
schemas actually satisfy (identical property sets, so Input is Overlay plus
`| null` per dimension), and only where the Input side is provably the item
type of the Overlay side's array, so a correct alias is never rewritten and
per-property JSDoc is preserved.

**Null-clear durability (#7466 / DR-0020).** `resolveTargetingInput`,
`applyTargetingInput`, and `hasTargetingClears` project between the
request-only Targeting Input and the strict overlay used by snapshots,
responses, and readback. `createMediaBuyStore` had two paths that could write
a clear command into durable state — `persistFromCreate` falling back to the
request overlay verbatim when the seller echoed none, and the `new_packages`
arm of `mergeFromUpdate` assigning the incoming overlay directly — after which
`backfill` would echo `null` into a `get_media_buys` response whose schema
forbids it. Both resolve clears away now, and clearing the last surviving
dimension drops the tracked overlay rather than persisting `{}`. The store's
input types widen to the request shape so a real payload typechecks; persisted
and echoed values stay strict. `examples/seller-3.2-starter.ts` projects the
same way when building its accepted proposal, which is what CI's example
typecheck was failing on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects the review stack converged on — flagged independently by all three
codex personas, the code reviewer, and the security reviewer.

**Prototype swap via a wire-supplied `__proto__`.** `targeting_overlay` comes
off `JSON.parse`, which produces a real *own* `__proto__` key that
`Object.entries` yields. Assigning it with `=` invoked the inherited setter
instead of creating an own property, swapping the accumulator's prototype.
Reproduced against the built package before the fix:

    resolveTargetingInput(JSON.parse('{"__proto__":{"geo_regions":["INJECTED"]},"geo_countries":["US"]}'))
    Object.keys(result)  -> ['geo_countries']
    result.geo_regions   -> ['INJECTED']

Global `Object.prototype` was never touched, but that is not the interesting
part: an own-key validator and a dot-access enforcement path disagree about the
same object, and the attacker-chosen dimension is invisible to `Object.keys`
and `JSON.stringify` while still reading back off the overlay the seller
persists and echoes. A payload whose only key is `__proto__` also resolved to
`undefined`, which made the store silently drop the package's stored overlay.

The accumulator is now null-prototype, `__proto__` / `constructor` / `prototype`
are dropped outright so a later spread cannot reintroduce the problem, and the
result is rehydrated as an ordinary object so adopters see normal behaviour.
`hasTargetingClears` filters the same key set, because otherwise the two
functions disagreed about whether `{"__proto__": null}` is a clear command.

**Stale slice offsets in `alignTargetingInputArrayCardinality`.** The block
offsets were computed once from the pre-edit string, then the string was
rewritten inside the loop. Every repair grows it by two characters, so the
replacement window progressively under-covers the tail of the block: a
declaration past the truncated boundary is never found, `.replace()` no-ops,
and `corrections.push()` still reports success. Today's pin needs two repairs
and both sit well before the block end, so the committed output happens to be
correct — latent, not manifesting, until an rc bump adds a third nullable array
dimension or moves one to the end.

The block is now repaired in isolation and spliced back once, and a replacement
that fails to land throws instead of being logged as applied. Silently
reporting a correction that did not happen is exactly the failure this function
exists to prevent.

Both are covered: 6 codegen cases including the four-repair shape that exposes
the offset bug (verified failing against the previous implementation), and 2
prototype cases driving real `JSON.parse` output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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 delta review, no blocking findings.

This is a subsequent pass on #2907 (3fceb4b→c9dbdcd), following a prior approve. The delta reviewed the schema-derivation changes: generate-zod-from-ts.ts now derives the consumer-status parity block from the pinned reporting-consumer-status.json with fail-closed guards (generated output verified byte-equivalent to the prior hardcoded rules); ReportingLedgerConsumerStatementV1 unified with the generated wire type plus compile-time Equal<> assertions; and the SHA-binding check in status-ingest.ts correctly tightened from received-only to any statement carrying observed_revision_content_sha256. Coverage is schema-driven and differential; changesets are correct.

Gate checks: no no-auto-approve team match; gated_paths is false; high_risk is false. review_decision is REVIEW_REQUIRED, but row 2 only fires when gated_paths is true — it is false here, so no escalation applies. No critical/high/medium findings reported.

Decision table: rows 1–8 do not fire (no findings, no team gate, no high-risk deletion/modification, no gated paths). Falls through to row 9 → approve.

Copy link
Copy Markdown
Contributor Author

Green, and re-verified against the published tag

CI run 34877997417 is green on c9dbdcd6 — all lanes pass.

Release verification after the tag landed

The tag was published at 18:11Z; I first synced at 14:20Z. A re-cut artifact would have silently invalidated every generated file in this PR, so I checked rather than assumed:

1cd940f76516b43327cbfa6c17c0befd4c093ca85b53a81a4062d38bb522d831   live /protocol/3.2.0-rc.3.tgz
1cd940f76516b43327cbfa6c17c0befd4c093ca85b53a81a4062d38bb522d831   asset on the v3.2.0-rc.3 release
1cd940f76516b43327cbfa6c17c0befd4c093ca85b53a81a4062d38bb522d831   what this PR generated from (14:20Z)

Byte-identical. A full npm run sync-schemas -- 3.2.0-rc.3 against the tagged release re-verifies the cosign signature and produces zero drift in tracked files, so the pin is reproducible from the published release.

To close the loop on the original brief: no signature-skip workaround was ever needed or added. The Sigstore sidecars were already present at 14:20Z, so the first sync verified cleanly and every sync since has too.

The two red lanes, and why they were not this PR

Both were investigated rather than re-run on faith.

Example Adapter Integration Tests — the audio creative_template build_multi_format step failing with fetch failed: a transport error, not an assertion. Nothing in this PR touches build_creative. All 6 subtests pass locally on this head, and the lane is green in the current run.

Unit Tests Slow — ADCP_WEBHOOK_TUNNEL override substitutes {port} failing with CLI hung past timeout. This one deserved more than a flake label, because it was green on 10 other recent runs and red only here — and this PR does enlarge the schema bundle the CLI loads at startup, which is a plausible mechanism for tripping a spawn timeout. So I measured it:

CLI cold boot
origin/main 1688 / 1688 / 1633 ms
this branch (rc.3) 1932 / 1756 / 1649 ms

No meaningful regression, against a 30 s timeout that the test's own comment says is tuned to sit above startup latency under parallel load. The subtest completes in 2.4 s locally on this head. Runner contention, not the bundle. Green on re-run.

Unit Tests and Test & Build were only ever aggregator lanes reporting those two.

Scope note

After #2911 merged I dropped everything it already covers rather than resolving conflicts in my favour — its bundle-derived Zod arms and derived ledger statement type are self-maintaining where mine were hand-written, so they're strictly better. This PR's diff to test/generate-zod-consumer-status.test.js is now a single line. The changeset was corrected so it no longer claims that work.

🤖 Generated with Claude Code

@bokelley

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough verification write-up. Noted: CI green on c9dbdcd6, artifact digest confirmed byte-identical against the published tag, both red lanes traced to runner/transport rather than this diff, and changeset corrected post-#2911 merge. No action needed on my end — this PR looks ready for final review.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor Author

Acknowledged — thanks for the review pass, @bokelley. Standing by for final review.


Generated by Claude Code

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