Skip to content

Refuse a union from its shapes' own reasons, never by decoding a refused alternative - #2778

Merged
trissim merged 3 commits into
mainfrom
fix/union-fit-refusal
Oct 23, 2026
Merged

trissim merged 3 commits into
mainfrom
fix/union-fit-refusal

Conversation

@trissim

@trissim trissim commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

UnionDecoder.decode_candidates (field_codec.py, from #2763 f228d09) fell back to decoding the first alternative when every shape check refused the value: tuple(filter(fits, self.alternatives)) or self.alternatives[:1]. That decode existed only to give the refusal a cause. But whenever fits_wire and the decoder disagree (#2763 fixed one such case, the Pi contextUsage.percent key), the fallback decode succeeds and the union silently accepts a value its own shape check refused.

Slop: IDEN-3. The fact is whether a wire value fits an alternative. It is answered twice, at decode_candidates by fits and by decode, and the fallback takes whichever accepts. It should derive from the decoder's fits_wire alone.

Change

  • ValueDecoder.shape_refusal(data) states why fits_wire refused. It never decides acceptance; fits_wire remains the only answer.
  • decode_candidates deletes or self.alternatives[:1]. When no alternative fits, it raises the union's shape_refusal, which names every alternative's reason with the first as the cause, and decodes nothing. A disagreement between a shape check and its decoder is now a loud refusal, never a silent accept.
  • Each shape owner states its own reason:
    • records: RecordProblems with missing and unknown keys;
    • family records, leaf documents, arrays and fixed tuples;
    • represented values, through their wire structure.
  • RecordDecoder.checked, FamilyRecordDecoder.member, the array readers and FixedTupleDecoder.plan_items now refuse through the same method, so each refusal text is written once.
  • The hot path is unchanged: fits_wire is untouched, and refusals are built only on the error path.

Tests

  • tests/test_field_codec.py: 64 passed, including test_union_success_releases_rejected_payload_and_failure_keeps_first_cause. Its first-cause message now comes from the shape refusal.
  • The same file with only the fallback deleted and no shape_refusal: 1 failed (that test).

🤖 Generated with Claude Code

…sed alternative

UnionDecoder.decode_candidates fell back to decoding the first alternative when
every shape check refused the value, so the refusal kept a cause. When the shape
check and the decoder disagreed, that decode succeeded and the union accepted a
value its own shape check had refused: two answers to "does this value fit", and
the fallback took whichever said yes.

- ValueDecoder.shape_refusal states why fits_wire refused a value. It never
  decides acceptance; fits_wire stays the only answer.
- decode_candidates raises the union's shape_refusal when no alternative fits,
  naming each alternative's reason and decoding nothing; the first reason is the
  cause.
- Records, family records, leaf documents, arrays, fixed tuples and represented
  values state their own reasons. RecordDecoder.checked, FamilyRecordDecoder.member,
  the array readers and FixedTupleDecoder.plan_items now refuse through the same
  method, so each refusal text is written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trissim
trissim marked this pull request as ready for review October 23, 2026 13:33
Copilot AI balanced review requested due to automatic review settings October 23, 2026 13:33

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

Copy link
Copy Markdown

debt-src at 76f268f, every finding:

grew TypeErrors:src/agent_comms/field_codec.py +1

Citation fixture and others added 2 commits October 23, 2026 09:38
shape_refusal added a fourth _member_decoder(self.target) call to
_LeafDocumentDecoder, and with it a fourth mypy arg-type error (debt-src
TypeErrors 114 -> 115). The leaf's member decoder is one fact; the
leaf property now declares it and fits_wire, shape_refusal,
decode_document and rewrite_document derive from it. Mypy on
field_codec.py: 112 (base 114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trissim
trissim merged commit 0266d7d into main Oct 23, 2026
6 of 8 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.

2 participants