Skip to content

feat(verification): per-version heartbeat fan-out + JWT adcp_version (#3524 stage 2)#3579

Merged
bokelley merged 2 commits intomainfrom
bokelley/per-version-badges-stage2
Apr 30, 2026
Merged

feat(verification): per-version heartbeat fan-out + JWT adcp_version (#3524 stage 2)#3579
bokelley merged 2 commits intomainfrom
bokelley/per-version-badges-stage2

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Stage 2 of #3524. Stage 1 set up the data model; this PR turns on the per-version issuance path.

Today with `SUPPORTED_BADGE_VERSIONS = ['3.0']`, runtime behavior is byte-for-byte identical to Stage 1 — but the wiring is in place. Flipping the constant to `['3.0', '3.1']` later turns on parallel-version badge issuance with no further code changes.

Architecture

A single `comply()` call still runs per agent (one network round per heartbeat). The flat storyboard-status list it returns is filtered per supported version before each `processAgentBadges()` call. Storyboards opt into a version via the SDK's existing `Storyboard.introduced_in` field — unset means "always applied," so today every storyboard is included for any 3.x target. When 3.1-only storyboards land they'll declare `introduced_in: "3.1"` and the 3.0 fan-out skips them.

What ships

  • `SUPPORTED_BADGE_VERSIONS` in `services/adcp-taxonomy.ts` — the explicit on/off switch. Adding `'3.1'` is a deliberate decision, not auto-derived from storyboards (so a stray 3.1-tagged storyboard doesn't accidentally start issuing 3.1 badges before AAO is ready).
  • `getStoryboardsForVersion(adcpVersion)` + `compareAdcpVersions` in `services/storyboards.ts` — the version filter. Numeric comparator (`'3.10' > '3.2'`), the same lesson Stage 1's review caught with the text `ORDER BY`.
  • Heartbeat fan-out in `addie/jobs/compliance-heartbeat.ts` — iterates `SUPPORTED_BADGE_VERSIONS`, narrows storyboard statuses to the version's applicable IDs, calls `processAgentBadges` once per version. Notifications aggregate across versions for one notification per agent.
  • JWT `adcp_version` claim in `services/verification-token.ts` — added to `VerificationTokenPayload` and signed alongside the existing claims. Validated at sign time against `^[1-9][0-9]*.[0-9]+$` so a poisoned DB row can't smuggle a malformed value into an AAO-signed token. Per Q4 of the resolved decisions, `protocol_version` (full semver) stays as informational metadata; `adcp_version` is the load-bearing badge identity claim.

What does NOT change

  • Badge SVG label still reads "Media Buy Agent (Spec)" without a version segment — Stage 3.
  • Verification panel still renders one row per role — Stage 4.
  • brand.json enrichment shape unchanged — Stage 5.

Test plan

  • 12 new unit tests across 3 test files
    • `compareAdcpVersions`: 6 cases including the load-bearing `'3.10' > '3.2'` invariant
    • `getStoryboardsForVersion`: catalog filter contract, predicate equivalence with the comparator
    • `SUPPORTED_BADGE_VERSIONS`: shape, type guard
    • JWT `adcp_version` round-trip, omit-when-absent, drop-on-malformed (SQL-injection-shaped + leading-zero), double-digit minor preservation
  • 121/121 unit tests pass total
  • TypeScript typecheck clean
  • Heartbeat behavior with `SUPPORTED_BADGE_VERSIONS = ['3.0']` is identical to pre-Stage 2 — same single round of badge issuance per agent

Review focus

  • The aggregation of `issued` / `revoked` across the per-version loop in the heartbeat — is the single combined notification the right call, or should each version fire its own?
  • The filter ordering: `introduced_in` unset → always applied. A future contributor adding a 4.0 storyboard MUST tag it `introduced_in: "4.0"` or it will get tested against 3.0 agents. The contract is documented; whether to additionally require non-empty `introduced_in` for new storyboards is a separate policy decision.

🤖 Generated with Claude Code

@bokelley bokelley force-pushed the bokelley/per-version-badges-stage2 branch from 56a32c1 to e50d83d Compare April 30, 2026 00:55
bokelley and others added 2 commits April 29, 2026 21:08
…3524 stage 2)

Stage 1 set up the data model; Stage 2 turns on the per-version
issuance path. Today with SUPPORTED_BADGE_VERSIONS=['3.0'] the
runtime behavior is byte-for-byte identical to Stage 1 — but the
wiring is in place, so flipping the constant to add '3.1' later
turns on parallel-version badge issuance with no further code
changes.

A single comply() call still runs per agent. The returned storyboard
statuses are filtered per supported version (using the SDK's existing
Storyboard.introduced_in field) before each processAgentBadges()
call. Storyboards without introduced_in are "always applied" — every
target keeps them — so today a 3.0 target reads the entire catalog.
When 3.1-only storyboards arrive they'll declare introduced_in: "3.1"
and the 3.0 fan-out skips them.

Files:
- services/adcp-taxonomy.ts: SUPPORTED_BADGE_VERSIONS constant +
  isSupportedBadgeVersion type guard
- services/storyboards.ts: compareAdcpVersions numeric comparator
  ('3.10' > '3.2'), getStoryboardsForVersion + getStoryboardIdsForVersion
  filters
- addie/jobs/compliance-heartbeat.ts: iterates supported versions,
  filters statuses per version, aggregates issued/revoked across
  versions for a single notification per agent
- services/verification-token.ts: adcp_version claim added to
  VerificationTokenPayload and signed alongside existing claims;
  validated against ^[1-9][0-9]*\.[0-9]+$ at sign time so poisoned
  DB rows can't smuggle malformed values into AAO-signed tokens
- services/badge-issuance.ts: passes adcpVersion into signVerificationToken

12 new tests cover the comparator (especially the '3.10' > '3.2'
case that motivated it), the version filter contract, the new
constant + type guard, and JWT round-trip with adcp_version
including double-digit minors and malformed-value rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security review (M1, M2, M3):

- M2 / fail-closed JWT signing. signVerificationToken used to drop a
  malformed adcp_version silently and emit a token without it. That
  converted any poisoned DB row into a downgrade-attack vector — a
  decentralized verifier reading the token would assume "no
  adcp_version = pre-Stage-2 legacy, accept as authoritative." Now
  refuse to sign and log loudly; the heartbeat surfaces it via
  notifySystemError. Updated 2 tests; added a third covering the
  full-semver-as-adcp_version programmer-error case.
- M1 / verifier-side adcp_version shape check. verifyVerificationToken
  now applies the same `^[1-9][0-9]*\.[0-9]+$` regex at verify time so
  a future signer bug, test key, or smuggled claim is rejected
  symmetrically.
- M3 / per-version try/catch in heartbeat fan-out. A failure on 3.1
  used to throw out of the loop and skip the notification for an
  already-completed 3.0 issuance, with the outer catch silently
  warning. Now each version has its own try/catch, partial-success
  notifications still fire for completed versions, and per-version
  failures emit notifySystemError so persistent issues aren't silent.

Code review:

- adcp_version is now threaded into BadgeIssuanceResult items
  (issued/revoked/degraded/unchanged) and into the
  notifyVerificationChange shape. Slack/DM/feed-event text now
  includes "(AdCP 3.0)" so two simultaneous issuances at different
  versions don't render as duplicate identical messages.
- Shared ADCP_VERSION_RE constant in verification-token.ts; nit caught
  the duplication across sign-time, verify-time, DB CHECK.
- compareAdcpVersions: comment honest about coercion, malformed inputs
  emit a debug log so a hand-edited storyboard YAML doesn't disappear
  silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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