Generalizes the lesson from the iter_image_url_values fail-open regression (code_reviews/pr-3-capability-gate-followups-review.md): a plan that proposes collapsing N implementations into one shared helper has to specify what it yields on every input shape, including the "well-typed but unparseable" case that the N originals never had to agree on explicitly. Gives a contract-table template for future plans to use, and a watch list of dispatcher.py traversals that aren't currently merged/broken but would need the same treatment if they ever are. Starts code_plans/ as where forward-looking spec/plan reports go, alongside code_reviews/ for after-the-fact review reports. No code changes.
7.1 KiB
Spec + implementation plan: pin down what a shared helper yields on well-typed-but-unparseable input
Origin. PR #3 collapsed four independent messages → image_url part
traversals into one generator, capabilities.iter_image_url_values
(code_reviews/capability-gate-followups.md, item #6). The plan specified
the two shapes the four originals agreed on (image_url as a dict with
url, or as a bare string) but not the case where a part is typed
image_url and carries neither — well-typed, but nothing to extract. The
merged helper silently skipped that case instead of yielding a placeholder,
which dropped a degenerate part out of two fail-closed checks at once (see
code_reviews/pr-3-capability-gate-followups-review.md for the live
repro and the fix). The follow-up opencode session that implemented the fix
named the general lesson explicitly, which is what this document is
formalizing: a plan that proposes collapsing N implementations into one
shared helper has to specify the helper's contract on every input shape,
not only the ones the N originals happened to agree on.
This is a process document, not a bug report — no code changes accompany it. It exists so the next plan that proposes a shared helper pins this down up front instead of it being rediscovered in review.
Spec
The problem, generalized
When several independent implementations of "walk this structure and pull out X" get unified into one shared helper, each original's handling of its own edge cases has to be re-derived by the person writing the unification — nothing forces them to enumerate it, because the edge case was never the reason any of the N implementations were written in the first place. Each one just happened to fall into some fallback path. The happy path is easy to unify because all N agree on it by construction (they all worked, on real traffic, for the common shape). The edge cases are exactly where they are most likely to quietly disagree, and disagreement there is invisible unless a plan makes each implementation's edge-case behavior explicit before proposing the replacement.
The specific edge case that bit PR #3 — matches the coarse type tag but the
payload is missing or empty — deserves its own name because it is a
distinct category from "wrong type" or "absent": it is a well-typed but
unparseable input. A traversal that filters on part.get("type") == "image_url" before doing anything else will always let this shape through
the filter; what happens next is the part that has to be decided
deliberately.
The rule
Any implementation plan that proposes extracting or unifying a shared traversal, parser, or extractor helper from multiple existing call sites must include an explicit contract: for every input shape the helper will see, state what it yields or returns. At minimum, enumerate:
- The common/valid shape — the one all N originals handle the same way.
- Every alternate valid shape any existing call site already handles
(there may be more than one — PR #3 had three:
image_urlas{"url": ...},image_urlas a bare string, and a bareurlkey on the part itself). - The well-typed-but-unparseable shape — passes the coarse type/shape
check but the value cannot actually be extracted. State the yielded
value explicitly (a sentinel like
"", not "nothing" / "skip") unless skipping is a deliberate, stated decision. - The wrong-typed or absent shape — fails the coarse check. Usually "skip", but say so, so it's a decision and not an assumption.
And critically: for category 3, list what each of the N originals currently does, not just what the new helper will do. That list is what surfaces disagreement before the merge, rather than after. In PR #3, all four originals agreed by accident (each one's "nothing extracted" path happened to either count the part or fail closed) — but nothing in the plan recorded that agreement, so the merge didn't have to preserve it and didn't.
Why "skip silently" is the default failure mode to watch for
A for/if/continue traversal skips by construction unless a branch
explicitly appends/yields on every path. Converting four such loops into
one yield-per-match generator is a natural, idiomatic simplification —
and it's exactly the transformation that turns "always contributes
something, even a fail-closed placeholder" into "contributes nothing,
silently, for a whole category of input." This isn't specific to
generators — the same risk applies to a shared parser that returns None
for the unparseable case where callers previously each had their own
None-handling that didn't all agree — but generators make it easy to
introduce, because continue-without-yielding reads as the most natural
thing to write.
Implementation plan
This is a discipline to apply going forward, not a code change. Concretely:
1. Template for future plans
When a code_plans/ (or code_reviews/) entry proposes extracting a
shared helper from multiple call sites, include a table shaped like this
alongside the proposed code:
| Input shape | Original A | Original B | Original C | ... | New shared helper |
|---|---|---|---|---|---|
| common/valid | ... | ... | ... | ... | |
| alternate valid #1 | ... | ... | ... | ... | |
| well-typed, unparseable | ... | ... | ... | ... (state the sentinel) | |
| wrong-typed / absent | skip | skip | skip | skip |
If any two originals disagree in a row, that disagreement is the thing the plan has to resolve on purpose (pick one, document why) rather than let the merge resolve by accident.
2. No retroactive audit needed right now, but a watch list for next time
Checked the rest of dispatcher.py for other traversals of a message's
multimodal content list, since that's the same shape that bit PR #3.
None of these are currently merged/unified helpers, so none are broken —
they're independent, single-purpose traversals, not N-collapsed-into-1. But
if any of them are ever unified (with each other, or with
iter_image_url_values), this spec applies:
session_fingerprint(dispatcher.py:1302) — joins text parts to fingerprint a session.session_directory(dispatcher.py:1321) — joins text parts to find a working directory.estimate_prompt_tokens(dispatcher.py:1364) — sums text-part lengths for the context estimate._last_user_text(dispatcher.py:1391) — joins the last user turn's text parts.
All four already treat a non-dict or non-string part as "contributes
nothing" consistently with each other (each filters with
isinstance(p, dict) / isinstance(p.get("text"), str) before including
it), so there's no live disagreement to flag today — this is a watch list,
not a finding.
3. Where this lives
Keep this document as the reference the next relevant plan cites, rather
than duplicating the rule inline each time. A future plan proposing a
shared-helper extraction should link back to this file
(code_plans/shared-helper-unparseable-input-contract.md) and fill in the
contract table from §1 as part of the plan, the same way
code_reviews/capability-gate-followups.md gave concrete before/after code
for each finding rather than describing it in prose only.