Skip to content

feat(api-sync): deterministic spec-sync patcher (Phase A) - #58

Merged
ericviana merged 9 commits into
mainfrom
eric/deterministic-api-sync
Aug 4, 2026
Merged

ericviana merged 9 commits into
mainfrom
eric/deterministic-api-sync

Conversation

@ericviana

@ericviana ericviana commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Phase A of the deterministic API-sync project: replaces the AI-driven (claude-code-action) api-sync step with a deterministic, dependency-free patcher, mirroring check_contract.py's ast-based style.

  • .api-sync/spec-map.json: curated mapping from spec constructs (enums, schemas, and inline nested sub-objects via specPath) to SDK symbols. Covers all 84 schemas reachable from paths/webhooks in the current public spec, including the customers (3-way KYC) and bank_accounts (11-rail) discriminator fan-outs, and the shared TrackingPayment/TrackingTransaction/TrackingComplete/TrackingLiquidity nested-object mappings shared between payin/payout. ignore.schemas documents the Rfi family, UploadAnalyze*, and the one confirmed orphan schema (LedgerOperation, 0 $ref anywhere in the filtered spec).
  • .api-sync/unmodeled.json: honest ledger (142 entries) of spec properties currently absent from their mapped SDK type, and enum value/member divergences, each with a reason and owner (eric@blindpay.com). Two kinds: property and enum.
  • .api-sync/sync.py: --check (state reconciliation against spec-snapshot.json), --apply (reconciles against --spec, hard-fails on removals/required-ness or type changes (including nullability)/new operations or schemas/unmapped targets/ambiguous fan-out targets/unresolvable types, applies enum-member and optional-property additions, refreshes the snapshot by copying the delivered spec's raw bytes), --validate-map, --coverage (non-blocking known-gap report). Reachability is the transitive $ref closure of paths+webhooks, computed fresh every run, so schemas outside it are skipped by construction, never by a hand-maintained list -- this is exactly what should prevent a future orphan schema (like LedgerOperation today) from generating phantom work.
  • tests/test_api_sync.py (51 tests): Literal/TypedDict splicing (incl. the _XRequired/total=False two-part pattern and the totality-driven NotRequired rule), import management, every NEEDS_HUMAN classification (including type-change and nullability-change), a nested specPath sub-object case (the exact blind spot that hid provider_reference -- see below), bump classification, idempotency, map validity, unmodeled.json honoring, and a byte-identity regression test for the snapshot refresh.
  • CI: new API Sync Check job (map validity, --check, a determinism proof, a non-blocking coverage report) wired into main.yaml alongside contract-check.
  • .github/workflows/api-sync.yml: rewritten. Drops anthropics/claude-code-action and every CLAUDE_CODE_OAUTH_TOKEN reference. Reads .api-sync/spec-current.json from origin/api-sync-data, runs the patcher, exits quietly on no-op, fails loudly (::error::) on NEEDS_HUMAN, otherwise commits + opens/updates the PR with a feat:/fix: title prefix derived from the patcher's own bump classification and enables auto-merge. api-sync-merged.yml is untouched (nothing it does depends on what was replaced).
  • .github/workflows/release-auto-merge.yaml (new): enables auto-merge on the release-please PR, gated strictly to github.event.pull_request.user.login == 'github-actions[bot]' AND the autorelease: pending label. Never touches a human PR; never bypasses required checks.
  • client.py version fix (separate commit): moved the single source of truth to src/blindpay/_version.py (imported by both __init__.py and client.py, no circular import), so the previously-hardcoded, silently-stale __version__ = "2.3.0" User-Agent can't drift from the package version again. Updated pyproject.toml's [tool.hatch.version] path and release-please-config.json's extra-files accordingly.

Pending drift applied (Phase A deliverable)

All three were already true of the committed spec-snapshot.json -- pending drift the code never caught up to, not new spec surface delivered by this PR:

  1. refund_wallet_address added to CreateQuoteInput (resources/quotes/quotes.py) as NotRequired[Optional[str]]: new optional field on QuoteIn. CreateQuoteInput is a total=True TypedDict, so a bare Optional[str] would have made the key structurally required and broken every existing caller that omits it -- caught in review, see "Post-review fixes" below.
  2. BankingPartner (types.py): missing "portage" (spec's banking_partner enum on VirtualAccountOut/CreateVirtualAccountIn has 5 values, the Literal had 4).
  3. PaymentMethod (resources/payins/quotes.py -- a local Literal shadowing the shared types.py one, confirmed via map-validity that CreatePayinQuoteInput.payment_method actually resolves to the local symbol): missing "transfers", "pse", "international_swift" (spec's CreatePayinQuoteIn.payment_method enum has 9 values, the local Literal had 6). Confirmed against blindpay-v2 (packages/reference/src/rails.ts) and the other 4 SDKs (node/go/php/swift all already carry all 9) that this was staleness, not a deliberate subset.

funding_end_to_end_id/payer_tax_id (on LedgerOperation) and the originally-assumed provider_reference field were investigated and are not simple field-adds -- see Findings below.

Post-review fixes

Two correctness bugs were caught in review and fixed before this PR was considered done:

  1. Breaking-change bug in field emission. The first version of the patcher chose the new field's annotation by matching sibling style within the target class. For CreateQuoteInput (a total=True TypedDict whose other fields happen to be typed as plain Optional[str]), that produced refund_wallet_address: Optional[str] -- a structurally required key, since total=True means every declared key must be present regardless of whether its value type allows None. Existing callers that build a CreateQuoteInput without the new key would fail pyright/mypy: a breaking change to the published 3.0.0 surface, which is the project's first constraint. Fixed: the emitted form now depends only on the target class's totality, never on sibling convention -- total=False (or the non-required half of the _XRequired two-part pattern) stays bare Optional[T]/T; total=True always gets NotRequired[Optional[T]]/NotRequired[T], unconditionally. The patcher also now adds NotRequired to whichever existing typing/typing_extensions import already carries TypedDict in that file, since quotes.py imports Optional from typing but had never needed NotRequired. Verified: pyright and mypy both pass with the new key omitted entirely.
  2. Missing nullability comparison in type-change detection. The old-vs-new diff compared base JSON type (string/integer/etc.) but not nullability, so a property flipping between ["T","null"] and "T" passed silently in either direction -- exactly the silent-divergence failure mode this project exists to prevent. Fixed: nullability changes are now flagged needs-human symmetrically (same treatment as required-ness changes), with one deliberate, tested exception: a property with no "type" key at all on either side (pure metadata-only change) is treated as compatible, since there's nothing concrete to compare -- this is the real, benign shape this spec's own created_at/updated_at fields went through.

What the type comparison does not cover: it only catches drift going forward from a synced baseline (old spec vs new spec for a property the SDK already models). It does not independently verify that the SDK's pre-existing annotation already matches the spec today, independent of any change -- that would be a materially different, larger audit, not a change-detection pass.

Findings beyond the assigned drift

The map-validity check and the bootstrap diff surfaced several more real, pre-existing gaps while building the map. None were auto-applied; each needed either confirmation of staleness (like the 3 above) or a human decision (recorded below with reason + owner, not silently dropped):

  • LedgerOperation (holds funding_end_to_end_id/payer_tax_id) has zero $ref anywhere in the filtered public spec -- an unreachable orphan. In ignore.schemas.
  • provider_reference does exist, just nested: it's on the inline tracking_payment sub-object of PayoutOut/PayoutOnEvmOut/4 payout webhook schemas (never $ref'd, duplicated inline 6x). The SDK's shared TrackingPayment models 6 of the wire's 20 payout-side fields; 14 are unmodeled (provider_reference, provider_integration, provider_error_reason, provider_uetr, provider_imad, provider_clearing_system, recipient_name/tax_id/bank_code/branch_code/account_number/account_type, coelsa_id, end_to_end_id). Recorded in unmodeled.json, recommended as one deliberate, reviewed follow-up PR across all 5 SDKs (exposing the richer payout tracking surface -- including recent UETR/provider_reference capture work -- is genuinely valuable, just not a blind per-field add).
  • Related nested gaps also recorded: payout-side tracking_transaction (missing ledger_in_transaction_id/ledger_out_transaction_id/provider_error_reason) and tracking_complete (missing provider_transaction_id/refund_reason); payin-side tracking_transaction has a ~15-field wire shape barely modeled by the generic 4-field TrackingTransaction -- and a fuller GetPayinTrackingTransaction TypedDict already exists in payins.py as dead code (never referenced).
  • BankAccountType enum value mismatch (not a missing member): spec is [checking, saving], SDK Literal is [checking, savings]. A live defect (not just typing) confirmed against blindpay-v2's own account_type === 'saving' branching. Kept mapped (not dropped -- dropping would make it invisible again) with the divergence recorded in unmodeled.json; needs a coordinated rename PR, deliberately not auto-fixed here.
  • KycStatus (customers.py): SDK Literal has 2 values, CustomerOut.kyc_status has 8. Kept mapped with the divergence recorded; needs a human decision on whether to add the 6 missing members or whether this field was wired to the wrong Literal from the start.
  • CreateWalletIn.name: required by the API, never sent by CreateCustodialWalletInput (only customer_id/network) -- flagged as a likely functional bug, not just a modeling gap.
  • ~130 additional smaller property gaps (bank-account rail fields, fee schedule rails, customer additional_info, etc.) seeded into unmodeled.json with individual reasons.

Proof

$ uv sync --group dev --group test
Resolved 28 packages in 3ms
Checked 27 packages in 5ms

$ uv run ruff format --check .
71 files already formatted

$ uv run ruff check .
All checks passed!

$ uv run pyright
0 errors, 0 warnings, 0 informations

$ uv run mypy .
Success: no issues found in 68 source files

$ uv run pytest --tb=short -q
...
192 passed in 0.89s

$ python3 .api-sync/check_contract.py
Checked 1343 declared TypedDict fields across 175 classes against 527 known wire keys.
Direction B (fields) -- warning only: 232 spec property name(s) are not declared by any SDK TypedDict. Not a failure; the SDK is allowed to model a subset of the API.
Direction A and B: PASSED.

$ python3 .api-sync/sync.py --validate-map
Map validity: OK

$ python3 .api-sync/sync.py --check
(silent, exit 0)

Determinism proof (two independent copies, applied from a true pre-drift state against the delivered spec, after the NotRequired fix):

$ python3 .api-sync/sync.py --apply   # copy A
Applied 1 change(s); bump=patch
  [property] src/blindpay/resources/quotes/quotes.py: added field `refund_wallet_address: NotRequired[Optional[str]]`
$ python3 .api-sync/sync.py --apply   # copy B
Applied 1 change(s); bump=patch
  [property] src/blindpay/resources/quotes/quotes.py: added field `refund_wallet_address: NotRequired[Optional[str]]`
$ diff -rq /tmp/notreq-proof-a /tmp/notreq-proof-b
(no output -- byte-identical trees)

(The enum-only and full-3-item determinism/idempotency proofs from the initial commits, also byte-identical across independent copies, are unaffected by this fix -- BankingPartner and PaymentMethod are Literal-member additions, not TypedDict fields.)

Type audit (--audit-types, non-blocking)

diff_removals_and_changes only catches type drift going forward from a synced baseline -- it can't see a pre-existing latent mismatch that was already there when a field was first modeled. Added --audit-types: a full, non-blocking (always exit 0) state comparison of every mapped property's spec type/nullability against the SDK's current annotation, reusing the existing coarse_type/SCALAR_TYPE_MAP primitives rather than a new engine. Wired into the API Sync Check CI job as a fourth, always-green, printed-only step.

Run against this repo's current spec: 316 findings, all informational.

  • 293 are "spec nullable, SDK annotation has no Optional[...]". Overwhelmingly a byproduct of a recent, broad, defensive nullable-almost-everything pass this spec's own JSON Schema generation went through (the same shape as the created_at/updated_at fields referenced in the snapshot-format fix commit) -- not 293 distinct bugs, but a real, low-urgency, systemic pattern worth someone's attention in Phase C.
  • 21 are enum-constrained spec properties modeled as a bare str (no Literal) -- a mix of legitimate free-form fields and real missing-Literal opportunities, e.g. Payin.type/.status/.payment_method and the shared Tracking*.step fields across types.py.
  • 2 look like genuine, worth-triaging issues, not modeling choices:
    • PayinOut.billing_fee_amount: number on the wire, Optional[str] in the SDK (Payin in payins.py) -- a financial amount modeled as a string.
    • VirtualAccountOut.blockchain_wallet: a real {network: enum, address: nullable string} object on the wire, collapsed to a bare Optional[str] in the SDK (VirtualAccount in virtual_accounts.py) -- the network/address sub-fields aren't modeled at all.

Deliberately not flagged: SDK wider than the spec needs (Optional[...] where the spec is never null, float where the spec is integer) -- common, harmless, legitimate modeling choices. Nothing here is fixed in this PR; it's Phase C triage material, exactly as this audit exists to surface rather than fix.

Not done here (deliberately out of scope for Phase A)

  • Fixing BankAccountType/KycStatus/the payout tracking_payment gap -- recorded, not applied, per above.
  • Enabling GitHub's "Allow auto-merge" repository setting -- currently off (gh api repos/blindpaylabs/blindpay-python --jq .allow_auto_merge -> false). Both release-auto-merge.yaml and api-sync.yml's own auto-merge step need it; gh pr merge --auto will fail until it's turned on in Settings -> General -> Pull Requests.
  • CLAUDE_CODE_OAUTH_TOKEN removal from repo secrets (out of this PR's control; the workflow no longer references it).

Per the plan: nothing merges here until reviewed.

https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs

Replaces the AI-driven api-sync pipeline's classification/apply step with a
deterministic, stdlib-only patcher (.api-sync/sync.py), mirroring
check_contract.py's ast-based style.

- .api-sync/spec-map.json: curated mapping from spec constructs (enums,
  schemas, and nested inline sub-objects via specPath) to SDK symbols.
  Covers all 84 schemas reachable from paths/webhooks in the current public
  spec, including the customers and bank_accounts discriminator fan-outs and
  the shared TrackingPayment/TrackingTransaction/TrackingComplete/
  TrackingLiquidity nested-object mappings. ignore.schemas documents the Rfi
  family, UploadAnalyze*, and the one confirmed-orphan schema (LedgerOperation).
- .api-sync/unmodeled.json: honest ledger of currently-absent spec properties
  and enum-member/value divergences on mapped schemas/enums, each with a
  reason and owner, seeded from a bootstrap diff against the current spec.
- .api-sync/sync.py: --check (state reconciliation against
  spec-snapshot.json), --apply (reconciles against --spec, hard-fails on
  removals/required-ness or type changes/new operations or schemas/unmapped
  targets, applies enum-member and optional-property additions, refreshes the
  snapshot), --validate-map, --coverage (non-blocking known-gap report).
  Reachability is computed as the transitive $ref closure of paths+webhooks,
  so schemas outside it are skipped by construction, not by a hand-maintained
  list.
- tests/test_api_sync.py: literal/TypedDict splicing (incl. the _XRequired
  total=False two-part pattern and the NotRequired convention), every
  NEEDS_HUMAN classification, a nested specPath sub-object case, bump
  classification, idempotency, map validity, and unmodeled.json honoring.
- CI: wires an API Sync Check job (map validity, --check, a determinism
  proof, a non-blocking coverage report) into main.yaml alongside
  contract-check.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
Ran .api-sync/sync.py --apply against the current public spec. All three
changes were already present in the committed spec-snapshot.json (pending
drift the code never caught up to, not new spec surface):

- BankingPartner (types.py): spec's banking_partner enum on
  VirtualAccountOut/CreateVirtualAccountIn has 5 values; the Literal only had
  4 (missing "portage").
- PaymentMethod (resources/payins/quotes.py): CreatePayinQuoteInput.payment_method
  resolves to this file's own local Literal (6 values), not the shared
  types.py one; the spec's CreatePayinQuoteIn.payment_method enum has 9
  (missing "transfers", "pse", "international_swift"). Confirmed against
  blindpay-v2 and the other 4 SDKs that this is staleness, not a deliberate
  subset.
- refund_wallet_address (resources/quotes/quotes.py): new optional field on
  QuoteIn, added to CreateQuoteInput.

Updated tests/resources/test_quotes.py's fixtures for the new required-key
(CreateQuoteInput is a total=True TypedDict, so every call site must supply
every key).

`.api-sync/sync.py --check` is now green. Two independent applies of this
same input produced byte-identical trees (determinism proof).

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
…rmatting

cmd_apply was re-serializing the whole spec via json.dumps(..., sort_keys=True)
to refresh spec-snapshot.json, which reorders every key in a 1.6MB file on
every apply and buries the real (few-line) drift under tens of thousands of
unrelated diff lines. Writing spec_path.read_bytes() straight through keeps
whatever formatting the upstream filter already produced and makes future
applies show only the lines that actually changed.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
…a duplicate literal

client.py hardcoded __version__ = "2.3.0" for the User-Agent header, separate
from __init__.py's __version__ = "3.0.0". release-please's extra-files only
patches __init__.py, so client.py silently drifted every release.

Moves the single source of truth to src/blindpay/_version.py, imported by
both __init__.py (public re-export, unchanged for consumers) and client.py
(no circular import: _version.py has no dependency on the rest of the
package). Updated pyproject.toml's [tool.hatch.version] path and
release-please-config.json's extra-files to point at the new file.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
…elivered spec

Regression coverage for the raw-bytes-copy fix in c858f1c: asserts
--apply --spec <file> leaves spec-snapshot.json byte-identical to that file
even when it uses compact separators, non-ascii content and no trailing
newline -- formatting a json.load/json.dump round trip would silently
normalize away.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
Drops the anthropics/claude-code-action step and every CLAUDE_CODE_OAUTH_TOKEN
reference. The workflow now: reads .api-sync/spec-current.json from
origin/api-sync-data, runs .api-sync/sync.py --apply, and:
- exits 0 quietly when there is nothing to apply,
- fails loudly (::error:: annotations) when the patcher reports NEEDS_HUMAN,
- otherwise commits the regenerated code + refreshed spec-snapshot.json to
  the api-sync branch, opens/updates the PR with a title prefix derived from
  the patcher's own bump classification (feat:/fix:) so release-please
  computes the right version bump, labels it api-sync, and enables
  auto-merge so the existing required checks are the only gate.

Keeps the .github/workflows/ commit guard from the old Claude-driven flow as
defense in depth, even though spec-map.json can never point there.

api-sync-merged.yml is unchanged: it only clears the api-sync-data baseline
files and does not depend on anything replaced here.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
Adds a workflow that enables GitHub's native auto-merge on the
release-please PR as soon as it exists, instead of waiting for a human to
click merge. Gating is strict and narrow: only fires when
github.event.pull_request.user.login == 'github-actions[bot]' AND the PR
carries release-please's own "autorelease: pending" label. This never
touches a human-authored PR, and enabling auto-merge does not bypass any
required check (lint, typecheck, tests, contract-check, api-sync-check,
snyk) -- GitHub still waits for all of them before merging.

With this, a merged api-sync PR now flows all the way to PyPI (via
publish.yaml's existing release-please -> tag -> uv publish path) with zero
human involvement.

Note: this repo currently has "Allow auto-merge" OFF at the repository
level (gh api repos/blindpaylabs/blindpay-python .allow_auto_merge is
false); both this workflow and api-sync.yml's own auto-merge step need it
turned on (Settings -> General -> Pull Requests) or `gh pr merge --auto`
will fail. Left as a manual follow-up rather than changing repo settings in
this PR.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
@ericviana ericviana added the api-sync Automated SDK sync with blindpay API label Aug 3, 2026
@BernardoSM

BernardoSM commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

…ed total=True TypedDict

A real PR review caught a breaking-change bug in the applied drift: `class
CreateQuoteInput(TypedDict):` has no `total=False`, so every key is
structurally required regardless of whether its value type is
Optional[...]. The previous choose_field_annotation matched sibling style
(plain Optional[str], since that's what CreateQuoteInput's other fields use)
and emitted `refund_wallet_address: Optional[str]` -- a REQUIRED key. Any
existing caller building a CreateQuoteInput without the new key now fails
pyright/mypy, which is exactly the "no breaking change to the published
surface" constraint this whole project exists to enforce. It's why the
previous commit had to add "refund_wallet_address": None to four call sites.

Fix: the emitted annotation now depends only on the target class's totality,
never on sibling convention:
- total=False (or the non-required half of the `_XRequired` two-part
  pattern): a bare Optional[T]/T is already optional, matching the existing
  behavior.
- total=True (the default): always NotRequired[Optional[T]] / NotRequired[T],
  regardless of whether any sibling field already uses NotRequired.

Also adds ensure_name_imported(): NotRequired (or Optional) is added to
whichever existing `from typing import ...` / `from typing_extensions
import ...` line already carries TypedDict, matching that file's own
convention, since quotes.py imports Optional from typing but had never
needed NotRequired before.

CreateQuoteInput.refund_wallet_address is now
`NotRequired[Optional[str]]`; the four test_quotes.py call sites that had to
explicitly pass `"refund_wallet_address": None` are reverted, since omitting
it is exactly what this fix makes safe again. pyright/mypy both pass with
the field omitted, proving the regression is closed. Bump stays "patch".

Also fixes a related, smaller gap found during the same review pass: the
old-vs-new type-change comparison only compared base JSON type
(string/integer/etc), never nullability, so a spec property flipping between
["T","null"] and "T" passed silently in either direction. Now flagged as
needs-human symmetrically, with one deliberate exception: a property with no
"type" key at all on either side (pure metadata, e.g. created_at gaining an
explicit type where it previously had none) is treated as compatible, since
there is nothing concrete to compare.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
…y mapped property's type

diff_removals_and_changes only ever catches type/nullability drift going
forward from a synced baseline -- it cannot see a pre-existing latent
mismatch between the spec's declared type and the SDK's annotation that was
already there when a field was first modeled. This closes that blind spot
the same way state reconciliation replaced event diffing for presence: a
full, non-blocking state comparison, reusing the same primitives
(coarse_type, SCALAR_TYPE_MAP) instead of a new engine.

`--audit-types` walks every mapped `types` entry, compares each modeled
property's spec type/nullability against the SDK's actual annotation text,
and prints a report. Always exits 0 -- this is Phase C triage material, not
a gate; a pile of pre-existing mismatches must not block CI. Flags:
- spec nullable, SDK annotation missing Optional[...] (a real gap: a caller
  could see None where the type says it can't happen);
- an enum-constrained property modeled as a bare scalar (no Literal);
- a scalar type mismatch (e.g. spec number, SDK str), with integer->float
  treated as a deliberately compatible widening;
- spec array/object vs a non-List/non-nested-TypedDict SDK annotation.
Deliberately NOT flagged: SDK wider than the spec needs (Optional where the
spec is never null, float where an int would do) -- a legitimate, common,
harmless modeling choice, not a bug.

A single map entry can list several spec locators asserted to share one
shape (e.g. tracking_payment duplicated inline across 6 payout schemas);
findings are deduplicated by (file, symbol, field, note) so the same real
issue is not reported once per locator.

Run against this repo's own current spec: 316 findings, all non-blocking.
293 are "spec nullable, SDK missing Optional" -- overwhelmingly the recent,
broad, defensive nullable-almost-everything pass this spec's own
created_at/updated_at fields went through (see the c858f1c commit message),
not 293 distinct bugs. 21 are enum-constrained properties modeled as a bare
str (a mix of legitimate free-form fields and real missing-Literal gaps,
e.g. Payin.type/status/payment_method and the shared Tracking*.step fields).
Two look like genuine, worth-triaging issues: PayinOut.billing_fee_amount is
`number` on the wire but `Optional[str]` in the SDK (a financial amount
modeled as a string), and VirtualAccountOut.blockchain_wallet is a real
{network, address} object on the wire but collapsed to `Optional[str]` in
the SDK (the network/address sub-fields are not modeled at all). None fixed
here -- Phase C triage, per the ask.

Wired into the API Sync Check CI job as a fourth, non-blocking, always-green
step next to the coverage report.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
@ericviana
ericviana merged commit 3f157fe into main Aug 4, 2026
9 checks passed
@ericviana
ericviana deleted the eric/deterministic-api-sync branch August 4, 2026 00:08
ericviana added a commit that referenced this pull request Aug 4, 2026
Literal ended with "2500000000_plus" (2.5 billion). The spec's enum on
CreateCustomerIn, UpdateCustomerIn, CustomerOut and both customer webhook
schemas is:
["0_99999", "100000_999999", "1000000_9999999", "10000000_49999999",
 "50000000_249999999", "250000000_plus"]

250000000_plus (250 million) is the only coherent reading: the band directly
below it tops out at 249999999, so the top band has to start at 250000000.
A business customer selecting the top revenue band sends a value the API
rejects -- the same class of live defect as account_type/BankAccountType
(#64).

Why .api-sync/sync.py's enum reconciliation did not catch this on its own:
it did not compare at all. EstimatedAnnualRevenue was never added to
spec-map.json's `enums` list during Phase A (#58) -- that list covers 17
shared, broadly-reused Literals, not the long tail of customer-domain-
specific ones customers.py declares (CustomerBusinessType, BusinessIndustry,
SourceOfWealth, TaxType, AmlStatus, ProofOfAddressDocType,
PurposeOfTransactions, SourceOfFundsDocType, and others -- roughly 15 more
Literals with no map entry at all). This is a map-coverage gap, not a
comparison-logic flaw: reconcile_enums only ever inspects what
spec-map.json lists, so an unmapped Literal is invisible to it regardless of
whether it has one extra member, one missing member, or both. Not
redesigning or expanding map coverage in this PR -- noting it as a real,
separate gap for a future pass.

Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-sync Automated SDK sync with blindpay API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants