Reviewing nominal API version bumps
Compare two published versions of the generated Nominal bindings and report what changed
and what it costs nominal-client to migrate. Works for both transports:
- conjure →
nominal-api(the Conjure HTTP bindings; one bignominal_api/_impl.py) - proto →
nominal-api-protos(gRPC/protobuf;nominal/protos/**/*_pb2.pyi+*_pb2_grpc.py)
The output classifies every delta as ⚠️ Breaking, ➕ Additive, or ℹ️ Behavioral, and — critically — scans the client so a breaking change with 0 references is flagged as a safe bump, while an additive with 0 references is flagged as a new capability that isn't wired up yet.
The default deliverable is a self-contained HTML report served on localhost (http://localhost:<port>/…);
the same content is also written as markdown.
When to use
- Bumping the
nominal-api/nominal-api-protospin inpyproject.toml. - Answering "what changed between 0.1282.0 and 0.1286.0?" or "is this bump safe to take?"
- Deciding what migration work a dependency upgrade implies before doing it.
Not for: editing the client to perform the migration (this skill only reports), or non-Nominal deps.
Quick reference
SKILL=.agents/skills/reviewing-nominal-api-bumps
# One-shot: pinned-in-pyproject -> latest on PyPI, scanning this repo
uv run --no-project python $SKILL/bump_scan.py conjure --repo .
uv run --no-project python $SKILL/bump_scan.py proto --repo .
uv run --no-project python $SKILL/bump_scan.py both --repo . --out ./reports
# Explicit versions
uv run --no-project python $SKILL/bump_scan.py conjure --repo . --old 0.1282.0 --new 0.1286.0
# Serve the built HTML on localhost (run in the background; prints http://localhost:<port>/…)
uv run --no-project python $SKILL/serve_report.py ./reports --file bump-report-0.1282.0-to-0.1286.0.html
bump_scan.py resolves versions (old = pin in pyproject.toml, new = latest on PyPI), installs both
versions, extracts each surface, diffs them, and scans nominal/ + tests/ for references. It writes
to --out (default .):
bump-report-<old>-to-<new>.html— a self-contained styled report (combined across transports forboth); the default deliverable, served on localhost byserve_report.py.migration-report-<transport>-<old>-to-<new>.md— the same content as markdown (greppable; the inline summary), and printed to stdout.
Then serve it: python serve_report.py <out> --file <name> binds 127.0.0.1 on a free port and prints
http://localhost:<port>/…. bump_scan.py prints the exact serve command to run.
How to run: use
uv run --no-project python ….--no-projectmakes uv ignore the project, so your.venvis never read, synced, or modified — whereas plainuv runwould sync it to the lockfile first. Bindings are fetched withuv pip install --targetinto a temp dir with the interpreter pinned and run outside the project, so.venvis untouched regardless. Barepython …works too (stdlib-only), anduv run --no-sync python …reuses the project interpreter without a sync. Onlyuvneed be on PATH.
Workflow
- Run the scan. Use
bump_scan.py <transport> --repo <client-root> --out <dir>. Prefer a scratch/temp--outdir so reports don't dirty the repo. For a full bump, runboth(one combined HTML page). - Serve it. Launch
serve_report.py <out> --file <name>in the background (it runs until killed) and give the user the printedhttp://localhost:<port>/…URL — this is the default way to view the report.bump_scan.pyprints the exact command. (The HTML is self-contained, sofile://works too.) - Read the verdict line.
✅ No breaking change references the client⇒ the pin bump is safe on its own.⚠️ N breaking change(s) touch the client⇒ open the Breaking table and address each. - Work the Breaking table. For each row with non-zero Client refs, open the listed
file:paths and update call sites for the exact change in theChangecolumn (e.g.~ inputs: List→Dict[str,…],− fill_strategy + alignment_configuration (req)). - Skim Additive for opportunities. Additive rows with non-zero refs mean the client already touches that type and could adopt the new optional field/endpoint. Rows with 0 refs are net-new capabilities — adopt only if there's a reason.
- Behavioral rows are enum members added to existing enums — revisit any exhaustive
match/ifover that enum in the client. - Relay the localhost URL + the verdict + the Breaking table inline.
Interpreting the report
- Client refs are textual matches of the element's leaf name in
nominal/+tests/. They are candidates, not proof: common names (File,Error,State) over-match unrelated code — verify the import context before trusting a count. A field-level change (e.g. a removed field) is scanned by the enclosing type's name, so alsogrepthe field name when triaging. - Rows whose key contains
Internalare internal/server-side services, sorted last; they rarely appear in the public client. Visitorclasses are skipped — a change to a union shows up on the union row itself.- Proto stub-generator boundaries. A proto diff that straddles a mypy-protobuf generator change
(e.g.
nominal-api-protos 0.1286 → 0.1287, which moved to keyword-only__init__and a different annotation vocabulary) shows widespreadChangechurn from type-string differences alone (_Optional[str]vs_builtins.str) even where the proto field is unchanged. Added/removed elements, enum members, and field names stay accurate; treat the bulk of type-only deltas at such a boundary as noise. Diffs within one generator era are clean.
The tools (run individually if needed)
bump_scan.py calls these in-process; run them standalone only for a custom flow.
extract_surface.py {conjure|proto} <unpacked-wheel-root>→ the structured surface as JSON Lines (one element per line, sorted by key). Line-diffable and greppable; types kept fully qualified soList→Dictis visible. Importable asextract(transport, root) -> list[Element].diff_surface.py <old.jsonl> <new.jsonl> --label "..." [--repo <root>]→ the markdown report. Classifies field-level deltas (removed field / new required field / type change / optional↔required) and renders the three severity tables. Importable asdiff_and_scan(old, new, repo),render_markdown(...),build_report(...).render_html.py→render_page(reports)builds the combined self-contained HTML page from the diffed data (styling inreport.css).serve_report.py <dir>serves it on localhost.
Common mistakes
- Trusting a ref count for a generic name.
Error/File/Stateover-match. Open the file. - Treating an additive optional field as breaking. New
Optional[...]fields and new endpoints never break existing callers — they live in the Additive table for a reason. - Default
newneeds network. It's resolved from the PyPI JSON API; if offline or it fails, pass--newexplicitly.uvmust be on PATH to fetch the bindings. - Running
protoagainst the conjure package (or vice-versa). The transport selects both the PyPI package and the extractor; don't mix them.