Skip to content

spec(idempotency): discriminated oneOf refactor (closes #2436)#2447

Merged
bokelley merged 2 commits intomainfrom
bokelley/issues-2435-2436
Apr 20, 2026
Merged

spec(idempotency): discriminated oneOf refactor (closes #2436)#2447
bokelley merged 2 commits intomainfrom
bokelley/issues-2435-2436

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Apr 20, 2026

Summary

  • Refactor adcp.idempotency from draft-07 if/then to a two-branch discriminated oneOf on supported: IdempotencySupported (requires replay_ttl_seconds) and IdempotencyUnsupported (forbids replay_ttl_seconds via not).
  • Wire format unchanged. Code generators that silently drop if/then (openapi-typescript, zod-to-json-schema, pre-0.25 datamodel-code-generator, quicktype) now emit two named types with the invariant at the type level.
  • Compliance storyboard narrative and validations updated to reference supported and clarify that supported: false sellers skip the replay storyboard.
  • Adds five Ajv fixtures (tests/composed-schema-validation.test.cjs) — two positive, three negative — locking the discriminator invariant.

Rebased onto main after #2434 and #2444 merged.

Why fold into the 3.0 RC instead of deferring to 4.0 (as the original #2436 proposed)

The deferral rationale in #2436 was "breaking change for SDKs generating against if/then." But supported was brand-new in #2434 when #2436 was filed — no SDKs had generated against the interim if/then shape yet. Shipping if/then at 3.0 GA and fixing at 4.0 would ship a known-bad shape for a whole release cycle.

Codegen spot-check (verified externally, per review feedback)

Ran both codegens against the bundled schema:

Pydantic v2 via datamodel-code-generator 0.54:

class Idempotency(BaseModel):
    supported: Literal[True]
    replay_ttl_seconds: conint(ge=3600, le=604800)  # required, not Optional

class Idempotency1(BaseModel):
    supported: Literal[False]

# idempotency: Idempotency | Idempotency1

TypeScript via openapi-typescript 7.13:

AdcpIdempotency: {
    supported: true;
    replay_ttl_seconds: number;  // required
} | {
    supported: false;
};

Both produce proper discriminated unions with the replay_ttl_seconds-when-supported invariant at the type level — exactly what the refactor promises. Minor nit on datamodel-code-generator: it names the unsupported class Idempotency1 instead of using the title (a known quirk for non-root oneOf subschemas); consumers can rename via codegen config.

Item-level if/then asymmetry — owning the call

sync-creatives-response.json still has an item-level if/then forbidding status when action ∈ {failed, deleted}. Same ergonomics trap, not fixed here. The reason isn't schema-refactor cost ($defs factoring is already used in 7 AdCP schemas including one response schema) — it's that status sits inside a family of action-gated optional fields: changes (only when action="updated"), errors (only when action="failed"), preview_url, expires_at, assigned_to, assignment_errors. None of those siblings have schema-enforced invariants. Splitting status alone into a oneOf would make it anomalously stricter than its siblings — internal-consistency cost without matching benefit. The right question is "should ALL per-item action-gated invariants convert to oneOf?" which is a 4.0-scope protocol-shape discussion, not a one-field refactor.

Blast-radius framing also differs: a dropped capability-level if/then produces silent retry-storm misconfiguration at scale (type system lets a buyer ship without TTL awareness). A dropped item-level if/then produces defensive null-checks on failed/deleted items that strict-typed buyers already write — unnecessary overhead, not corruption.

Tracked as #2450 for 4.0 review alongside the rest of the action-gated family.

Expert review + feedback addressed

Ran through ad-tech-protocol-expert, dx-expert, adtech-product-expert, and code-reviewer across two rounds. All four validated the capability-level fold-in; 3 of 4 validated the item-level deferral framing after the asymmetry reasoning was sharpened (code-reviewer's pushback corrected the original claim that $defs would be a novel pattern — it isn't, which reshaped this PR body and #2450).

Second-pass feedback addressed:

  • Tone alignment: IdempotencySupported.supported.description rewritten from meta-phrasing ("authoritative") to wire-behavior matching the unsupported branch.
  • Negative-path fixtures: {supported: false, replay_ttl_seconds: 3600}, {supported: true} (missing TTL), {} (missing discriminator) — all now exercised.
  • Codegen claim verified externally (above) rather than trusted.
  • Item-level asymmetry: sharpened reasoning in spec: discriminated-oneOf refactor for sync_creatives item-level status/action constraint #2450 around action-gated field family consistency, not schema-refactor cost.
  • not: {required: [replay_ttl_seconds]} kept over additionalProperties: false for forward-compat.

Test plan

  • npm run test:schemas — 7/7 passing
  • npm run test:examples — 31/31 passing
  • npm run test:json-schema — 249 schema-annotated JSON blocks passing (includes hand-written idempotency examples)
  • npm run test:composed — 17/17 passing (was 12, +5 new oneOf fixtures: 2 positive, 3 negative)
  • npm run test:extensions — 20/20 passing
  • Codegen spot-check: openapi-typescript 7.13 + datamodel-code-generator 0.54 both produce clean discriminated unions
  • Precommit (test:unit 587/587 + typecheck) — passing

🤖 Generated with Claude Code

bokelley and others added 2 commits April 19, 2026 22:13
…loses #2436)

Swap the draft-07 if/then conditional for a two-branch discriminated union
on `supported`: `IdempotencySupported` carries `replay_ttl_seconds` (required);
`IdempotencyUnsupported` forbids it via `not: {required: [replay_ttl_seconds]}`.
Wire format unchanged. Code generators that silently drop `if/then`
(openapi-typescript, zod-to-json-schema, pre-0.25 datamodel-code-generator,
quicktype) now emit two named types with the invariant at the type level.

Safe to fold into #2434 because `supported` is brand-new in that PR — no SDKs
have generated against the interim `if/then` shape yet. Deferring to 4.0 per
the original #2436 proposal would have meant shipping a known-bad shape for
one whole release cycle.

Compliance storyboard narrative and validations updated to reference the
new shape; `supported: false` sellers skip the replay storyboard.

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

Address review feedback on PR #2447:

- Replace meta-phrasing on IdempotencySupported.supported with wire-behavior
  description, matching the tone of IdempotencyUnsupported.supported.
- Add five schema fixtures exercising the oneOf discriminator: two positive
  (supported:true with TTL, supported:false alone) and three negative paths
  (TTL on unsupported, missing TTL on supported, empty block). The negative
  cases lock the `not: {required}` invariant that Ajv's default error text
  obscures.

Codegen spot-check verified externally: openapi-typescript 7.13 emits
`{supported: true; replay_ttl_seconds: number} | {supported: false}` and
datamodel-code-generator 0.54 emits two Pydantic classes with
`Literal[True]/Literal[False]` and required `replay_ttl_seconds` on the
supported branch — exactly the type-level invariant the refactor promises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/issues-2435-2436 branch from 71747db to 202fc63 Compare April 20, 2026 02:22
@bokelley bokelley changed the base branch from bokelley/issues-2428-2430 to main April 20, 2026 02:23
@bokelley bokelley merged commit c1d2ff1 into main Apr 20, 2026
3 checks passed
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