Skip to content

feat: gate the router exception docstrings with a per-bucket digest of the spec's meaning - #134

Open
mattmillerai wants to merge 1 commit into
mainfrom
matt/be-9891-router-meaning-digest
Open

feat: gate the router exception docstrings with a per-bucket digest of the spec's meaning#134
mattmillerai wants to merge 1 commit into
mainfrom
matt/be-9891-router-meaning-digest

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

ELI-5

The vendored Router contract writes a sentence of meaning prose for each of the 15 error buckets, and each RouterError subclass rewords that sentence into its docstring. The drift check only compared the bucket names and their order — so a contract sync that rewrote a bucket's retry guidance and nothing else passed CI clean, leaving the SDK docstring quietly describing prose that no longer exists. Now every class carries a short fingerprint of the exact meaning its docstring was written against; when the prose moves, CI goes red naming that bucket and printing the new fingerprint to paste back once you have re-read the docstring.

What changed

  • src/comfy_sdk/router_exceptions.py — a module-level _meaning_digest(meaning) helper (first 12 hex of sha256 of the whitespace-normalized prose), and a _spec_meaning_digest class attribute on each of the 15 RouterError subclasses, sitting right under error_type so the marker is next to the docstring it blesses. Every value was computed from the currently vendored spec/router-openapi.yaml. The attribute is deliberately absent from the RouterError base class: a subclass that forgets it has to fail the check via getattr(cls, "_spec_meaning_digest", None) rather than silently inherit a blessing for prose nobody read.
  • scripts/check_drift.py_declared_router_error_types() now returns validated {value, tier, meaning} entries instead of a bare value list, in the same actionable-ValueError style already used there (tier must be request/transport, meaning a non-empty string). _check_router_error_types() keeps its existing values-and-order comparison byte-for-byte identical in behavior (it just derives the flat value list from the entries) and then runs a digest pass on top.
  • tests/test_router_spec_contract.py — a parametrized test asserting the same digest equality with the same guidance in its message, plus a test that every request-tier entry precedes every transport-tier one. That ordering is the assumption that lets the flat order comparison stand in for a tier check; nothing was asserting it.
  • src/comfy_sdk/router_exceptions.py (counts) — the hardcoded bucket counts are gone from the two section comments and the ROUTER_EXCEPTIONS docstring ("the six … then the nine …"), so a sync cannot falsify them.
  • spec/README.md and AGENTS.md — both said a changed meaning was the one thing no check caught. Both now describe the read marker and the three-step sync (spec, class table, re-bless).

The design constraint, stated explicitly

The digest hashes the spec's meaning. It is never compared against the docstring, and it must not be "fixed" into one: these docstrings deliberately reword the prose into reST, so equality is impossible by design. The digest means "this docstring was written against this version of the meaning" — a question a checker can answer, where "does the docstring say the same thing" is not.

Verification of the failure path

Beyond the suite passing, I confirmed the new gate actually fires and actually clears, by temporarily mutating the tree and reverting each time (the vendored spec is unchanged in this diff):

  1. A changed meaning — flipped one word in not_enabled's prose (outageincident). check_drift.py failed with not_enabled: NotEnabled is blessed against 'bc789c2d6efb', and the spec's meaning hashes to 'c6ef19909d2d' plus the paste-back line; pytest failed on test_every_class_is_blessed_against_the_spec_s_current_meaning[not_enabled] with the same digest.
  2. A class that forgets the marker — deleted Forbidden's _spec_meaning_digest. Both went red with Forbidden carries no _spec_meaning_digest and the digest to add. The message branches on this case on purpose: telling someone their prose "changed" when they have simply not blessed a newly added bucket yet sends them diffing a spec that did not move.
  3. A whitespace-only reflow does NOT go red — rewrote one meaning as a folded YAML block scalar with identical words. All 54 contract tests stayed green, which is the point of normalizing with " ".join(meaning.split()): a re-wrap should not demand a re-read that has nothing to read.
  4. Interleaved tiers go red — flipped rate_limited to tier: request so a request-tier entry followed transport-tier ones; only the new tier-order test failed.

Residual

  • Uncovered spec prose, measured. The read marker covers the 15 x-comfy-error-types meaning entries — the whole corpus this change was scoped to. The same staleness risk exists for the rest of the contract's prose that the SDK reproduces in docstrings and comments: spec/router-openapi.yaml carries 63 other description: fields, none of which has a read marker, and spec/openapi.yaml is generated so its descriptions ride along in _generated.py and are covered by the existing byte-for-byte codegen gate instead. Extending the marker to the 63 hand-reworded router descriptions is a separate, larger question (there is no existing class-per-description table to hang a marker on) and is not attempted here.
  • The digest is a change detector, not a semantic one. It proves someone re-blessed after the prose moved; it cannot prove they actually re-read the docstring rather than pasting the new digest. That is inherent to a read marker and is stated in the docs this PR rewrites, but it is worth a reviewer knowing the gate's ceiling.
  • Line references in the originating request were stale and were not used. The task text pinned every edit to line numbers from an earlier branch head; the default branch has since advanced past it (a vendored Router spec sync and a docs change). I located every construct by content rather than by line, and the counts, section comments and docstring anchors all matched. Nothing in the request pointed at code that no longer exists.
  • One artifact could not be exercised. The upstream read-only investigation this work derives from — and its findings comment, which the request cites as carrying the supporting evidence — lives in an internal tracker that is not reachable from this environment. I implemented against the request's stated design and the repository's own state, both of which I could verify directly, but I did not read that evidence.

Provenance

  • Authored by: agent-work loop
  • Verified: uv run --extra dev pytest: 734 passed, 4 skipped; uv run --extra dev pytest tests/test_router_spec_contract.py tests/test_router_exceptions.py: 162 passed; ruff check .: all checks passed; ruff format --check .: 51 files already formatted; mypy src: no issues in 19 source files; python scripts/check_drift.py (codegen extra): all three checks OK. Failure path exercised in the four scenarios above and reverted.
  • Deviations: import hashlib is a module-level import rather than a local one inside _meaning_digest as the request's sample code wrote it — behaviorally identical, and it matches the file's existing import style. No other deviations; every step was implemented as specified.

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation of Router error definitions to detect mismatched values, ordering, metadata, and documented meanings.
    • Added safeguards to identify outdated or unacknowledged error descriptions, helping keep SDK behavior aligned with the Router specification.
  • Documentation

    • Clarified the process for reviewing and synchronizing Router error descriptions when specification meanings change.
    • Expanded documentation of Router error categories and consistency checks.

…f the spec's meaning

`spec/router-openapi.yaml`'s `x-comfy-error-types` entries each carry `meaning`
prose that the `RouterError` subclass docstrings reword. Until now
`scripts/check_drift.py` and `tests/test_router_spec_contract.py` compared only
wire values and declaration order, so a sync that rewrote a bucket's `meaning`
— its retry guidance, say — left the SDK docstring silently stale with
everything green.

Each subclass now carries a `_spec_meaning_digest`: the first 12 hex of sha256
of the whitespace-normalized `meaning` its docstring was written against, via a
new module-level `_meaning_digest` helper that the checker and the suite both
read, so the two can never disagree. It is a read marker, never a comparison
against the docstring — the docstrings deliberately reword the prose into reST,
so equality is impossible by design.

`_declared_router_error_types()` now returns validated `{value, tier, meaning}`
entries; `_check_router_error_types()` keeps its values-and-order comparison
unchanged and adds a digest pass that names the bucket, tells the reader to
re-read that class's docstring, and prints the digest to paste back. The marker
is deliberately absent from `RouterError` itself, so a subclass that forgets it
fails via `getattr(..., None)` rather than inheriting a blessing.

Also asserts that every `request`-tier entry precedes every `transport`-tier one
— the assumption that lets the flat order check stand in for a tier check — and
drops the hardcoded bucket counts from the section comments and the
`ROUTER_EXCEPTIONS` docstring, so a sync cannot falsify them.
@mattmillerai
mattmillerai requested review from a team as code owners September 6, 2026 00:11
@mattmillerai mattmillerai added agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review labels Sep 6, 2026
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 3027bcad-44fa-41cb-b68f-b330c6656d7a

📥 Commits

Reviewing files that changed from the base of the PR and between ce4242b and b1df022.

📒 Files selected for processing (5)
  • AGENTS.md
  • scripts/check_drift.py
  • spec/README.md
  • src/comfy_sdk/router_exceptions.py
  • tests/test_router_spec_contract.py

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


📝 Walkthrough

Walkthrough

The Router spec workflow now validates structured error metadata, declaration order, and per-class _spec_meaning_digest markers. Router exception classes define 12-character SHA-256 digests for their spec meanings. Tests and synchronization guidance enforce the updated workflow.

Changes

Router spec synchronization

Layer / File(s) Summary
Meaning digest metadata
src/comfy_sdk/router_exceptions.py
Adds _meaning_digest and records _spec_meaning_digest values on typed router exception subclasses.
Structured drift validation
scripts/check_drift.py
Parses value, tier, and meaning fields. It validates metadata, exception values, declaration order, and per-class meaning digests.
Contract tests and synchronization guidance
tests/test_router_spec_contract.py, spec/README.md, AGENTS.md
Adds tier-order and meaning-digest contract checks. Documents the three-step Router spec synchronization process.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to b1df0

This change adds digest markers and validation for Router error-spec meaning changes without altering public exception behavior. The added contract coverage and drift checks support merge readiness.

Sequence Diagram(s)

sequenceDiagram
  participant Spec
  participant DriftChecker
  participant ExceptionClasses
  participant ContractTests
  Spec->>DriftChecker: provide structured error declarations
  DriftChecker->>ExceptionClasses: resolve declared error classes
  ExceptionClasses-->>DriftChecker: return values and meaning digests
  DriftChecker->>ContractTests: validate values, order, and digests
Loading

Suggested reviewers: wei-hai

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 3 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: using per-bucket meaning digests to detect stale Router exception docstrings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 3 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-9891-router-meaning-digest

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions 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.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 5 finding(s).

Severity Count
🟡 Medium 1
🟢 Low 3
⚪ Nit 1

Panel: 6/6 reviewers contributed findings.

# `getattr(..., None)` because `RouterError` deliberately declares no
# default: a subclass that forgets the marker has to fail here rather than
# inherit a blessing for prose nobody read.
assert getattr(cls, "_spec_meaning_digest", None) == expected, (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — When the spec declares a bucket the SDK has no class for, exception_for(entry["value"]) returns the RouterError base, so this assertion fails with a message naming RouterError and telling the developer to set _spec_meaning_digest: str = "..." on it — putting the marker on the base class is exactly what this module's comments say must never happen, since every subclass would then inherit a blessing. check_drift.py is immune because its digest pass runs only after the value lists match; add the same guard here by asserting cls is not RouterError first and leaving the missing-class report to test_every_declared_bucket_has_a_class. Raised by 2 of 6 reviewers (claude-opus-5-thinking-max edge-case, kimi-k3-high edge-case).

Comment thread scripts/check_drift.py
if missing:
print(
f" declared in the spec, no class in the SDK: {', '.join(missing)}\n"
" Add one RouterError subclass per value to src/comfy_sdk/router_exceptions.py,\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — The missing-bucket remediation still describes only the pre-existing gate (add a subclass with the spec's meaning as its docstring) and omits the now-mandatory _spec_meaning_digest, so an operator who follows it literally hits a second, unrelated-looking failure from the new digest pass on the very next run. Mention setting the digest the check prints; spec/README.md's "A value was added" bullet has the same gap. Raised by 2 of 6 reviewers (kimi-k3-high adversarial, claude-opus-5-thinking-max edge-case).

Comment thread scripts/check_drift.py
return values
seen.add(value)
tier = entry.get("tier")
if not isinstance(tier, str) or tier not in ("request", "transport"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowtier is validated against a closed two-value set, but neither pass in _check_router_error_types ever reads it — only value and meaning — so the docstring's claim that these are "the three fields both passes below read" is wrong for tier, and a vendored sync that merely adds a third tier hard-fails the drift job over a field it does not check (with no remediation documented in spec/README.md). Consider validating tier as a non-empty string here and leaving the two-value assertion to the test that actually depends on it. Raised by 2 of 6 reviewers (claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

Comment thread scripts/check_drift.py
cls = exception_for(entry["value"])
# `getattr(..., None)` rather than an attribute read, and the base
# class deliberately declares no default: a subclass that forgets the
# marker has to fail here rather than inherit a blessing for prose

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — The comment's claim that a forgetful subclass cannot "inherit a blessing" holds only because every bucket class currently derives directly from RouterError: getattr walks the MRO, so a future bucket derived from another bucket would silently inherit that class's digest. cls.__dict__.get("_spec_meaning_digest") enforces the invariant as stated; the same applies to the equivalent getattr in tests/test_router_spec_contract.py. Raised by 2 of 6 reviewers (gpt-5.6-sol-max adversarial, claude-opus-5-thinking-max edge-case).

Comment thread scripts/check_drift.py
if not isinstance(entry, dict) or not isinstance(entry.get("value"), str):
raise ValueError(f"{ROUTER_SPEC.name} has an x-comfy-error-types entry with no value")
value = entry["value"]
if not value:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — This new guard rejects value on bare falsiness while meaning below is deliberately checked with .strip(), so a whitespace-only value passes here and the dedup set and is later reported as "declared in the spec, no class in the SDK: " with a blank-looking name. It also raises the identical message as the missing/non-string check three lines above, making two distinct malformations indistinguishable in CI output — strip the value and say "empty value" (or include the offending entry). Raised by 2 of 6 reviewers (claude-opus-5-thinking-max edge-case, kimi-k3-high edge-case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant