Skip to content

fix(tui): ship Anthropic input schemas verbatim - #146

Open
asto18089 wants to merge 5 commits into
pinvou3-cleanfrom
fix/anthropic-input-schema-verbatim
Open

asto18089 wants to merge 5 commits into
pinvou3-cleanfrom
fix/anthropic-input-schema-verbatim

Conversation

@asto18089

@asto18089 asto18089 commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The Anthropic Messages adapter unconditionally ran a Responses-style root flatten on every tool's input_schema. Two independent records frame the fix:

  • Empirical (genuine Anthropic): commit f8e7573 (2026-07-11, harvested from upstream PR fix: sanitize tool input_schema for Anthropic adapter codewhale-hq/Codewhale#4346) quotes a live rejection — Anthropic API error (HTTP 400 … invalid_request_error): tools.1.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level — and CHANGELOG §0.9.0 records the same rationale. Externally, [BUG] input_schema does not support oneOf, allOf, or anyOf at the top level anthropics/claude-code#4886 (2025-07) and #30212 (2026-03) report the identical server-side 400, both closed as not planned without ever being marked fixed; the same 400 recurs in #84056 (2026-08, closed not planned) and root-level allOf is still reported silently dropped in #95504 (open, 2026-09). Upstream still defends this lane today (d29abf3, 2026-11-03, RouteToolProjection::Relaxed retains sanitize_for_responses).
  • Documentary (other lanes): fetched 2026-11-05 and re-verified 2026-11-06 — the Anthropic tool-use guide defines input_schema simply as "A JSON Schema object defining the expected parameters for the tool"; the structured-outputs strict-restrictions list names no root-composition ban; the recorded 400 names only oneOf/allOf/anyOf, never enum/not; DeepSeek's Anthropic-compatible line documents input_schema as "Fully Supported" (api-docs.deepseek.com/guides/anthropic_api); MiniMax and Model Studio document no restrictions.

The first revision of this PR removed the flatten on every lane, which re-exposed the recorded failure mode on the genuine Anthropic lane — apply_patch (root oneOf) and finance (root anyOf) are model-visible built-ins, and MCP schemas carry no registry sanitization upstream of this adapter. This revision keeps the verbatim goal and scopes the exception to the lane with a recorded rejection.

Changes

  • New sanitize_for_anthropic_messages (crates/tui/src/tools/schema_sanitize.rs) narrows exactly the recorded keywords: strips root oneOf/anyOf/allOf, merges branch properties into the root, and surfaces the dropped exactly-one / at-least-one constraint as a description note. Unlike the old Responses-style flatten it preserves root enum/not, dependentRequired/dependentSchemas, the declared root type, and declared non-object roots — none are in any record. Where a stripped composition leaves a root with no declared type, the object type is restored (the old line always forced it) — unconditionally, so compositions whose branches carry no properties (allOf of $refs, required-only or boolean branches) still keep the object floor; when the merge contributes no properties at all, the dropped constraint is surfaced as a generic keyword-specific note so the model is not left without guidance for a fully erased argument contract. A root that is not a JSON object at all (malformed server or MCP output) is replaced by an empty object exactly as the Responses pass does, since the API defines input_schema as a JSON Schema object. The shared registry normalization (sanitize: nullable-union collapse, bare-object properties injection, dangling-required pruning) still applies first, and the doc comment carves that out explicitly.
  • build_anthropic_body (crates/tui/src/client/anthropic.rs) applies that pass on the first-party lane: ApiProvider::Anthropic or a base URL on api.anthropic.com (is_native_anthropic_base_url — the same endpoint predicate the adaptive/redacted-thinking gates in the same function already use), so a custom wire="anthropic" provider pointed at the genuine endpoint gets the same recorded-rejection protection; provider identity stays in the gate beside the endpoint because ANTHROPIC_BASE_URL overrides commonly front the genuine validator through a proxy. Everything else ships input_schema verbatim: the DeepSeek-Anthropic, MiniMax-Anthropic, and Model Studio Anthropic lanes, Openmodel, the OpencodeZen Message-capable route, dual-wire built-ins flipped by providers.<id>.wire = "anthropic", and custom providers pointed at third-party gateways.
  • One shared builder still serves every Messages lane.

Behavior

  • First-party Anthropic lane: the recorded triggering tools are unaffected — apply_patch and finance ship byte-identical to the previous release line, notes preserved (executed differential harness, now also a committed test). For arbitrary schemas the wire intentionally diverges where the old flatten acted without a record: only root oneOf/anyOf/allOf are stripped (branch properties merged, constraint note kept — or a generic note when the branches carry no properties); root enum/not, tree-wide dependentRequired/dependentSchemas, and declared non-object roots now ship verbatim; a non-object root (array/scalar) is still replaced by an empty object; a stripped composition leaving a schema with no declared root type gets type:"object" restored. MCP schemas on this lane keep the sanitize() normalization they had before; it remains their only normalization.
  • All other Messages lanes: input_schema ships exactly as declared, including root unions. Providers that reject such shapes surface their own provider error instead of a quietly altered contract.
  • strict passthrough untouched; the provider-neutral registry sanitizer for native tools unchanged; Responses/xAI/Kimi passes untouched.

Test evidence

  • non_anthropic_messages_lanes_ship_input_schema_verbatim_without_root_flattening: a root oneOf ships verbatim with no note on the DeepSeek, MiniMax, Model Studio, and Openmodel lanes.
  • non_anthropic_messages_lane_ships_a_schema_the_registry_would_normalize: a bare object (a shape the shared sanitize() would mutate) ships byte-identically on a verbatim lane, pinning that the else branch runs no normalization at all.
  • anthropic_lane_narrows_the_recorded_root_composition_keywords: union stripped, branch properties merged, note appended, empty description replaced by the note alone.
  • anthropic_identity_narrows_even_on_a_proxy_base_url: an ApiProvider::Anthropic client at a non-anthropic.com base URL still narrows — the only behavior distinguishing the identity disjunct from the endpoint clause.
  • anthropic_lane_keeps_unrecorded_keywords_verbatim: root type, dependentRequired/dependentSchemas, root not, and a non-object enum root all ship byte-identically on the Anthropic lane.
  • custom_anthropic_wire_clients_narrow_by_first_party_endpoint: a custom wire="anthropic" provider at api.anthropic.com gets the narrowing; the same provider shape at a third-party gateway ships verbatim.
  • Module-level pins in schema_sanitize.rs cover the sanitizer directly: strip scope with exact dependent-keyword fixtures, enum/non-object passthrough, merge + note text, the object floor and generic note for property-less compositions (allOf-of-$refs and boolean branches), nested-composition preservation (root-only strip scope), root-property-wins merge precedence, the note keyword precedence under a dual composition, the no-injection negative, non-schema-root replacement (array/scalar → {}), the explicit post-merge required prune (single-branch oneOf collapse corner), a root allOf strip/merge/note fixture, the shared-registry anchor (nullable-union collapse, bare-object injection, dangling-required pruning), and a committed previous-release-line equality test running the real apply_patch/finance schemas through both pipelines (registry pre-pass included, Value + serialized-bytes + note equality).
  • Mutations executed locally: verbatim-everywhere fails the narrowing test; sanitize_for_responses in place of the narrow pass fails the unrecorded-keywords test; dropping the shared sanitize() call fails the registry-anchor test; dropping the is_native_anthropic_base_url clause fails the custom-endpoint test; note-order reversal fails the exact-note assertions; removing the explicit post-merge prune, the non-object-root replacement, or the scoped type restoration each fails its own new pin while the negative pins stay green. Fix-wave mutations, each red on exactly its new pin: restoring the merged_properties conjunct fails the object-floor test; flipping the generic-note condition reds the object-floor and merge tests; widening the strip tree-wide fails the nested-composition pin; dropping the identity clause fails the proxy-base-URL test; normalizing the verbatim else-branch fails the registry-would-normalize test; last-wins merge precedence fails the root-property pin; swapping the note keyword order fails the dual-composition pin.

Known limitation: verbatim lanes that front genuine Claude

The verbatim rule is grounded in each provider's own documentation, but a wire="anthropic" lane can transitively front the genuine Anthropic validator: a custom provider pointed at a third-party gateway, or a gateway lane (Openmodel, OpencodeZen) serving claude-family models upstream. On such a lane a root union in a tool schema — including the every-turn apply_patch (root oneOf) and finance (root anyOf) built-ins — can surface the recorded rejection from the upstream validator where the previous release line silently flattened it (observed in the wild as client → one-api-style gateway → Claude upstream → root anyOf → 400 upstream_error). Doc-silence is the operative evidence on these lanes — DeepSeek's own endpoint has recorded rejections of other schema keywords (regex escapes, type: "custom") — so the trade-off is explicit: raw provider errors name the exact tool and keywords and are actionable, while narrowing every lane would re-alter contracts on providers that genuinely support unions. If this bites in practice, extending the recorded-rejection narrowing to every wire="anthropic" lane is a self-contained follow-up (a narrowed schema is valid for any upstream).

Merge-conflict surface with #137

Still edits crates/tui/src/client/anthropic.rs and crates/tui/src/tools/schema_sanitize.rs only; no chat.rs or config use-block changes, so no conflict surface with #137.

Fork-vs-upstream note

Upstream flattens every Messages lane (RouteToolProjection::Relaxed, which additionally strips root enum/not and forces type:"object"). This branch matches upstream behavior on the genuine Anthropic lane for the recorded keywords and diverges only on lanes whose providers document full support or no restrictions; the parent repository's fork register will need this noted when the gitlink advances.

Probe declaration

No live probes were used. The first-party-lane narrowing is grounded in the recorded in-repo and upstream receipts above; the verbatim lanes are grounded in the three fetched documents. If a live probe later shows the genuine endpoint now accepts root unions, the lane gate can be dropped in a follow-up.

No-Issue: provider compatibility batch grounded in recorded provider behavior and the published provider docs (fetched 2026-11-05, re-verified 2026-11-06); the repository has issues disabled.

…ng the root

The Messages adapter unconditionally ran the Responses-style root
flatten (strip oneOf/anyOf/allOf/enum/not at the root, merge branch
properties) on every tool's input_schema, commented as stripping shapes
"which Anthropic rejects". The 2026-11-05 document check finds no
such rejection: the tool-use guide defines input_schema as "A JSON
Schema object defining the expected parameters for the tool"
(platform.claude.com/docs/en/agents-and-tools/tool-use/
implement-tool-use) with no root-composition restriction, the
structured-outputs strict-restrictions list names none either, and
DeepSeek's Anthropic-compatible line documents input_schema as fully
supported. The rewrite had no document basis and silently altered
declared tool semantics, so the unconditional call is removed:
input_schema reaches the wire exactly as declared, the synthesized
constraint-note plumbing goes with it, and the provider-neutral
registry sanitizer upstream is unchanged. strict passthrough is
untouched. One shared builder change covers all four Messages lanes
(Anthropic, DeepseekAnthropic, MinimaxAnthropic, Modelstudio
Anthropic).

Signed-off-by: asto <asto18089@126.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

REQUEST_CHANGES — fresh multi-lane review

Fresh 8-lane review (pipeline map, history/upstream, scope/claims audit, local gates, live doc verification, mutation execution, adversarial falsification of the blocking finding). Rebase: no-op verified — head 91aa64569 merge-bases exactly at the current pinvou3-clean tip 11cba403dc; already on latest main. Local gates green (rustfmt; clippy under the fork-ci flag set; client::anthropic 39/39; schema_sanitize 64/64). Scope clean: one file, every hunk in-scope, nothing smuggled, hygiene compliant (DCO, No-Issue line; no subject-length rule exists in this repo).

The diff quality is good — but the removal's core premise conflates document basis with behavioral basis, and it re-introduces a recorded live failure mode on the genuine Anthropic lane.

MAJOR-1 — Removing the root-composition strip re-exposes a recorded live HTTP 400 on the genuine Anthropic lane

The three document claims are literally accurate (verified fetched 2026-11-05: the tool-use guide's "A JSON Schema object defining the expected parameters for the tool"; the structured-outputs strict-restrictions list names no root-composition ban; DeepSeek's Anthropic-compat page says input_schema "Fully Supported"). But this repository's own history records an empirical basis that the PR body never mentions or rebuts:

Consequences on ApiProvider::Anthropic (WirePolicy::Fixed(AnthropicMessages), provider.rs:1189) after this PR:

  • apply_patch (root oneOf, apply_patch.rs:372-376) and finance (root anyOf, finance.rs:219-223) are model-visible built-ins (spec.rs:1501-1503). They are deferred from the eager set (DEFAULT_ACTIVE_NATIVE_TOOLS, tool_catalog.rs:51-56), so the 400 lands on the first request after tool_search activates them — the standard workflow — and on every subsequent tool-bearing request in that turn.
  • MCP tool schemas are cloned verbatim with no sanitization anywhere upstream (mcp.rs:4065-4075 to_api_tools); Zod-style root-anyOf schemas now ship raw to a validator recorded rejecting them.
  • Adversarial trace found no counter-mechanism: registry sanitize preserves multi-branch root unions, prepare_model_bound_request touches only messages, strict_tool_mode merely unmarks strict, the evidence-mode projection is non-default and Bash/Run/File/Web-only. The PR's own new test positively pins the union reaching the wire (mutation-executed: with the old builder code re-added the test fails on exactly the union-verbatim assertion; passes on head).

The probe declaration says no live probes were used, so there is also no evidence the current endpoint accepts root unions. As merged, this trades "silently altered contract" for a recorded hard request failure on the flagship provider. Suggested directions (pick one):

  • (a) Route-aware, mirroring upstream's projection: ship verbatim on the lanes whose providers document full support (DeepSeek-Anthropic / MiniMax / Model Studio), keep the flatten for ApiProvider::Anthropic — ideally narrowed to exactly the recorded rejection (root oneOf/anyOf/allOf only; the recorded error never named enum/not).
  • (b) Keep verbatim everywhere, but only after a documented live probe showing api.anthropic.com accepts a root-union input_schema.
  • (c) If neither: keep the narrow strip on the genuine Anthropic lane and surface the constraint in the description note instead of dropping it silently.

MINOR-1 — "Schemas without root compositions are byte-identical to before" is overstated (body + new comment)

The removed call also stripped dependentRequired/dependentSchemas tree-wide (appending its own note), force-inserted root type:"object", replaced non-object roots with {}, and enforced object-valued properties. Registry sanitize/canonicalize_schema do none of that, so those schema classes change on the wire too — undisclosed in the behavior-changes list. The new builder/test comments' summary of the old behavior ("strip oneOf/anyOf/allOf/enum/not at the root, merge branch properties") inherits the same incomplete framing.

MINOR-2 — MCP framing

"The provider-neutral registry sanitizer that runs upstream of this adapter is unchanged" holds for the native registry but not for MCP tools, which reach MessageRequest.tools with no sanitization at all — for them the removed adapter call was the only normalization on Messages lanes (upstream even moved to normalize cloned MCP schemas in d29abf312). Worth one clarifying sentence so readers don't assume upstream coverage.

MINOR-3 — Test pins only root oneOf

A partial regression — re-adding only anyOf/allOf stripping, only the dependent-keyword strip, or only enum/not removal — passes the new test. If verbatim shipping survives MAJOR-1's resolution, add anyOf/allOf/enum/not/dependentRequired fixtures plus an empty-description case (the old code replaced an empty description with the note; now it ships as-is — intended but unpinned).

MINOR-4 — Fork-divergence registration (parent-repo follow-up, not this repo's gate)

Upstream still flattens, so merging this makes the fork behaviorally diverge on all Messages lanes. docs/fork-policy.md/fork-modifications.md live only in the parent repo; when this reaches the parent, the register/fingerprints/fork-guard will need the entry per the parent's policy.

Verified good (no action)

Single commit, single file, zero smuggled changes; strict passthrough untouched; the Responses/xAI/Kimi flattens correctly untouched; the four-lane claim is if anything an undercount (five static Anthropic-dialect variants plus wire=anthropic overrides all share the one builder, anthropic.rs:110 being the crate's only input_schema serialization site).

…ction

Ship input_schema verbatim on every Messages lane except the one whose
validator is recorded rejecting root compositions. The genuine Anthropic
endpoint has returned HTTP 400 "input_schema does not support oneOf,
allOf, or anyOf at the top level" since at least 2025-07 (f8e7573
quotes a live failure; CHANGELOG 0.9.1; anthropics/claude-code#4886 and
#30212 report the identical server-side error, neither marked fixed),
so ApiProvider::Anthropic now narrows exactly those three root keywords
via sanitize_for_anthropic_messages: branch properties merge into the
root, the dropped exactly-one / at-least-one constraint becomes a
description note, and everything the record does not name — root
enum/not, dependentRequired/dependentSchemas, the declared root type,
non-object roots — ships verbatim. DeepSeek-Anthropic, MiniMax-
Anthropic, and Model Studio Anthropic lanes keep the verbatim shipping:
DeepSeek documents input_schema as fully supported and the others
document no restrictions (fetched 2026-11-05).

For apply_patch and finance, whose root unions triggered the old
flatten, the Anthropic-lane wire bytes are identical to the previous
release line; schemas without root compositions change only where the
old flatten deleted unrecorded keywords.

Pins: the three non-Anthropic lanes ship a root oneOf verbatim with no
note; the Anthropic lane strips the union, merges branch properties,
notes an empty description, and preserves enum/not/dependent keywords
and a non-object enum root byte-identically. Module-level pins cover
the sanitizer directly.

Signed-off-by: asto <asto18089@126.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Fix wave pushed (head 48daada87, round 1 → all findings addressed)

Responding to the review round, all findings fixed on the branch:

MAJOR-1 — route-aware fix. The unconditional verbatim shipping is scoped: only ApiProvider::Anthropic — the lane with the recorded HTTP 400 … input_schema does not support oneOf, allOf, or anyOf at the top level (f8e757322, CHANGELOG §0.9.1, anthropics/claude-code#4886/#30212, upstream d29abf312 still defending it) — now narrows root compositions, via a new sanitize_for_anthropic_messages that strips exactly the three recorded keywords. Everything the record never names now ships verbatim even on that lane (root enum/not, dependentRequired/dependentSchemas, declared root type, non-object roots — the old flatten's unrecorded extras are gone, which also resolves the substance of MINOR-1). For apply_patch/finance the Anthropic-lane wire is byte-identical to the previous release line; DeepSeek-Anthropic / MiniMax-Anthropic / Model Studio lanes keep full verbatim shipping (review direction (a)).

MINOR-1/2 — accurate disclosure. Builder comment, sanitizer doc comment, and the PR body rewritten: the byte-identical claim is now scoped to what actually holds, and the MCP caveat is stated explicitly (MCP schemas carry no registry sanitization upstream, so the Anthropic-lane pass is their only normalization).

MINOR-3 — coverage. Three adapter-level tests now pin: verbatim shipping on all three non-Anthropic lanes (root oneOf intact, no note); the narrowing (union stripped, branch properties merged, note appended, empty description replaced by the note alone); and preservation of every unrecorded keyword (root type, dependentRequired/dependentSchemas, root not, non-object enum root byte-identical). Plus three module-level pins on the sanitizer itself.

MINOR-4 — register. Noted in the PR body's fork-vs-upstream section for the parent-repo follow-up when the gitlink advances.

Verification

  • cargo fmt --all -- --check clean; clippy under the fork-ci flag set clean; rustdoc build clean for both touched files (the four -Dwarnings rustdoc errors this workspace emits are pre-existing symbols untouched by the diff; fork-ci does not gate the rustdoc build).
  • client::anthropic 41/41; schema_sanitize 67/67.
  • Mutations, each killed by exactly the intended test: (M1) lane gate off → verbatim-everywhere → anthropic_lane_narrows_the_recorded_root_composition_keywords red; (M2) old sanitize_for_responses in place of the narrow pass → anthropic_lane_keeps_unrecorded_keywords_verbatim red; (M3) note plumbing dropped → narrowing test red. All green on the branch head.
  • Full local cargo test --lib run: aborts mid-runtime_api with a stack overflow identically on pristine 91aa64569 (reproduced on both heads, no panic report, unrelated module), i.e. a pre-existing local-harness/environment issue — CI's nextest run is the authoritative full-suite gate for this branch.

Rebase: base unchanged at 11cba403dc — single fast-forward commit on top of the reviewed head.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Fresh re-review (2026-11-06, 9 lanes, head 48daada87)

Full re-review from scratch at head 48daada87. Rebase check: no-op — pinvou3-clean tip is still 11cba403d (merge-base == base == head-parent), so the branch is current. CI 13/13 green. Nine review lanes ran: implementation deep-dive, adapter/provider matrix, firsthand receipt verification, scope/convention audit, an 8-mutation test ledger, a differential wire-identity harness, elegance/architecture, performance, and a coverage-funnel sweep.

Verdict: the PR's claims hold — byte-identity on the genuine Anthropic lane is proven rather than asserted, the receipts are real, scope is clean, perf is same-or-better, and the coverage funnel has no bypass. One MAJOR pin gap and a few MINORs below.

Verified sound

  • Receipts verified firsthand: f8e757322 quotes the live 400 verbatim and is the commit that introduced sanitize_for_responses to this adapter; [BUG] input_schema does not support oneOf, allOf, or anyOf at the top level anthropics/claude-code#4886 and #30212 contain the identical error, both closed not_planned (never marked fixed — which strengthens the compat case); upstream d29abf312 RouteToolProjection::Relaxed still runs sanitize_for_responses on the Messages path, and hmbown/main (984c4c21f) has nothing newer touching schema projection; DeepSeek's Anthropic-API docs mark input_schema "Fully Supported" (fetched).
  • Byte-identity proven: a differential harness on the real schemas — apply_patch, finance, and an MCP-shaped root-union schema — old path (sanitize_for_responses) vs new path (build_anthropic_body on ApiProvider::Anthropic) produce identical input_schema and description, note included. The no-composition case diverges exactly as the body describes.
  • Scope: exactly the two claimed files, every hunk in-scope, no dead code left from the first revision, cargo check -p codewhale-tui 0 warnings, cargo fmt --all -- --check pass (local clippy diffs are pre-existing on base in untouched files), DCO + No-Issue compliant.
  • Mutation ledger: M1 gate-off, M2 responses-flatten swap, M3 note suppression, M4 oneOf-removal, M5 merge-removal, M8 note ordering → all killed by the new tests.
  • Coverage funnel: one production call site (client.rs Messages arm) serves every Messages lane; no count_tokens path, no second body builder; MCP to_api_tools ships verbatim with no upstream sanitization, as claimed; strict×composition is impossible (strict_schema_supported rejects first); performance is strictly same-or-better on every lane (verbatim lanes drop two full-tree walks per request).

MAJOR-1 — the sanitize() anchor is load-bearing but unpinned (mutation M6 survived)

Deleting the sanitize(schema); call inside sanitize_for_anthropic_messages (schema_sanitize.rs:148) leaves all 77 anthropic + 67 schema_sanitize + verbatim-lane tests green, but changes wire bytes: a root nullable union (anyOf:[X,{"type":"null"}]) would ship the union verbatim — the exact recorded-rejection shape — instead of collapsing to X + nullable:true as the previous release line did (sanitize_for_responses also runs sanitize(), at :104). Bare-object properties:{} injection and the dangling-required prune are likewise unpinned. This pass is MCP tools' only normalization on the Anthropic lane, so the pin matters. Fix: a module test through sanitize_for_anthropic_messages pinning the nullable-union collapse (+ bare-object injection).

For the record, deleting prune_dangling_required at :158 is a provable no-op — sanitize() already prunes and the merge only adds properties — so M7 surviving there is acceptable defense-in-depth.

MINORs

  • M-1 gate predicate: the gate keys on provider identity, but the same function already encodes "first-party Anthropic endpoint" as is_native_anthropic_base_url (anthropic.rs:639, used at :176 and :257 for adaptive/redacted thinking). A custom provider with wire="anthropic" and base_url=https://api.anthropic.com (BYOK/proxy setups) is treated as first-party by the thinking gates but ships root oneOf verbatim → the recorded 400 resurfaces exactly there. Suggest || is_native_anthropic_base_url(&self.base_url) plus a pin, or a comment documenting why lane identity alone is the right predicate.
  • M-2 body accuracy: the CHANGELOG receipt is §0.9.0 (line 4543), not §0.9.1. And the verbatim set is larger than enumerated: Openmodel (Fixed AnthropicMessages, provider.rs), the OpencodeZen Message-capable route, and dual-wire built-ins flipped by providers.<id>.wire = "anthropic" all now ship verbatim too — unnamed and unpinned by the verbatim test.
  • M-3 doc overclaim: "the declared root type ships verbatim" is falsified by sanitize()'s nullable-union collapse (it inlines the non-null branch's type, rewriting the root; the old path masked this by forcing type:"object" afterward). Worth one doc clause carving out the shared normalization.
  • M-4 commit-1 bisect hazard: 91aa64569's message asserts the document check "finds no such rejection", which 48daada87 retracts on receipts, and commit 1's tree alone re-exposes the recorded 400. Acceptable for a 2-commit PR; squashing is optional.

NITs

  • The 18-line in-body comment (anthropic.rs:87-104) overlaps the 16-line doc comment on schema_sanitize.rs:126-142 — same receipts, same preserved-keyword enumeration; the mechanics clauses could live in one place.
  • The adapter-level unrecorded-keywords test overlaps the two module tests on schema shapes; defensible (it uniquely pins gate wiring + description passthrough), just noting the redundancy.

I'll push a fix wave for MAJOR-1, M-1, M-2, and M-3 shortly.

Fresh-review follow-ups on the narrowed Anthropic input_schema pass:

- Pin the shared sanitize() anchor inside sanitize_for_anthropic_messages:
  it is load-bearing (the previous release line collapsed root nullable
  unions through it) and deleting it left every new test green while a
  root anyOf:[X, null] would ship the recorded-rejection union shape.
- Key the narrowing gate on the first-party endpoint, not provider
  identity alone: a custom wire=anthropic provider pointed at
  api.anthropic.com now gets the same recorded-rejection protection the
  thinking gates apply via is_native_anthropic_base_url, and a gateway
  base URL still ships verbatim; both pinned at the adapter level.
- Correct the CHANGELOG receipt (0.9.0, not 0.9.1) and carve the shared
  registry normalization out of the ships-verbatim doc claims.

Signed-off-by: asto <asto18089@126.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Fix wave pushed: 698c41e3f (fast-forward on 48daada87), addressing all four findings from the fresh review:

MAJOR-1 (sanitize anchor unpinned) — fixed. New module test anthropic_messages_sanitize_keeps_the_shared_registry_normalization pins the shared sanitize() anchor through sanitize_for_anthropic_messages: root anyOf:[X,{"type":"null"}] collapses to X + nullable:true (never ships the recorded union shape), bare objects keep the properties:{} injection, dangling required is pruned. Mutation re-run: deleting the sanitize(schema); call now fails this test (was green before the pin). The doc comment now also carves the shared normalization out of the ships-verbatim claims (M-3), so "declared root type ships verbatim" is no longer read past the nullable-collapse rewrite.

M-1 (gate predicate) — fixed. The gate is now matches!(self.api_provider, ApiProvider::Anthropic) || is_native_anthropic_base_url(&self.base_url) — the same endpoint predicate the adaptive/redacted-thinking gates in this function already use. A custom wire="anthropic" provider pointed at api.anthropic.com gets the recorded-rejection narrowing; one pointed at a third-party gateway still ships verbatim. New adapter test custom_anthropic_wire_clients_narrow_by_first_party_endpoint pins both sides; mutation re-run: dropping the endpoint clause fails it.

M-2 (body accuracy) — fixed. PR body now cites CHANGELOG §0.9.0 and enumerates the full verbatim set (Openmodel, the OpencodeZen Message-capable route, dual-wire providers.<id>.wire flips, third-party-gateway customs); the stale 0.9.1 reference in the adapter comment is corrected too.

M-4 (commit-1 bisect hazard) — left as-is (2-commit history, commit 2's message accurately describes the net tree; squashing pushed history for this was judged not worth it).

NITs — the in-body comment was rewritten while fixing M-1/M-2 and now carries the shared-normalization carve-out; the test-shape overlap between adapter and module levels is kept deliberately (the adapter test uniquely pins gate wiring + description passthrough).

Local verification on 698c41e3f: cargo test -p codewhale-tui --lib anthropic 79/79, --lib schema_sanitize 68/68, cargo fmt --all -- --check pass, cargo check -p codewhale-tui 0 warnings; clippy shows only the two collapsible_if hits pre-existing on base in untouched files (turn_loop.rs:5611, subagent/mod.rs:8060; local clippy 1.99 drift — CI's pinned toolchain accepts the same lines). Net diff of the fix wave: 2 files, +133/−10 (2 tests + 1 helper + gate clause + comment/doc corrections).

Round-3 fresh-review follow-ups on the narrowed Anthropic input_schema
pass, each found by a dedicated review lane:

- Replace a root that is not a JSON object at all (malformed MCP or
  server output) with an empty object, mirroring
  sanitize_for_responses: the API defines input_schema as a JSON
  Schema object, and shipping the raw value verbatim re-exposed the
  whole-request HTTP 400 failure class this pass exists to prevent.
  Declared non-object schemas (a string-enum root) still ship
  verbatim.
- Restore type:"object" when stripping a root composition leaves a
  populated object schema whose root declared no type: the previous
  release line always forced the type, and the merge otherwise ships
  a typeless schema that invites non-object arguments.
- Pin the explicit post-merge prune (a single-branch oneOf spreads its
  required groups after sanitize's own prune ran), add the missing
  root-allOf strip/merge/note fixture, pin the no-injection negative,
  and make the dependent-keyword fixtures well-formed with exact
  assertions instead of is_some() checks.
- Correct the remaining stale CHANGELOG 0.9.1 citations (the rationale
  is recorded under 0.9.0) and document the replacement and type
  restore in the module doc, the pass doc comment, and the adapter
  call-site comment.

Signed-off-by: asto <asto18089@126.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-3 fresh review — 11 lanes, from scratch (head now 78da100)

Third from-scratch review of this PR (prior rounds' conclusions treated as reference only). Rebase first: pinvou3-clean is unchanged at 11cba403dc and the branch merge-base equals its tip, so the rebase was a no-op; the branch was already on the latest base. Eleven parallel lanes re-derived everything: gates/CI, scope+commit hygiene, receipts re-verification, completeness/coverage funnel, feature-bug hunt with executed repros, performance, elegance/conventions, mutation ledger, differential wire-identity harness, adversarial hunt, and conversation/upstream radar. Findings below; everything actionable was fixed in 78da100 (pushed, CI running).

The four review questions

1. Actually useful, root-caused, complete — YES, re-verified firsthand today.

  • The receipts stack is solid and uncontradicted as of 2026-11-06: f8e7573 quotes the live 400 verbatim; claude-code#4886/#30212 verified via the API (closed not-planned, never fixed); the same 400 recurs in #84056 (2026-08) and root-level allOf is still silently dropped per #95504 (open, 2026-09) — two newer corroborations now cited in the PR body; upstream main still flattens all lanes today (nothing after d29abf3 touches the adapter; its Relaxed projection strips strictly more — root enum/not and forced type:"object").
  • Completeness: build_anthropic_body has exactly one production caller (client.rs prepare_outbound_request, WireFormat::AnthropicMessages); no count_tokens path exists; deferred/tool_search activation re-enters the same gated builder (catalog clones → active_tools_for_step → turn_loop → gated build); MCP schemas ride in raw and are gated on this lane; checkpoint/resume/eval rebuild through the same funnel. The construction matrix (provider catalog + wire overrides + custom providers) classifies correctly under the gate; a custom wire="anthropic" provider with no base URL resolves to a loopback placeholder, so gate and destination cannot diverge.

2. Elegant, no reinvention — YES. The new pass reuses root_composition_constraint_note/merge_root_composition_properties/prune_dangling_required/sanitize; a keyword-set-parameterized shared core with sanitize_for_responses was assessed and correctly rejected (only ~5 of ~10 steps are shared; divergence is the point). The inline gate matches the same file's per-lane predicate style, reusing is_native_anthropic_base_url. pub surface, naming, and test style follow house conventions.

3. Smuggled changes — NONE. Full diff touches exactly two files and every hunk serves the new pass, the gate, or their tests. Commit-1's message asserts the conclusion the PR later retracted (transient bisect hazard only — this repo squash-merges, so intermediates never ship).

4. Bug-free — one MAJOR found and fixed; the rest MINOR/NIT.

  • MAJOR (fixed in 78da100): the new pass dropped the base's non-object-root guard, so a literal non-object input_schema (array/scalar from a malformed MCP server — verified raw-clone path, no object guard upstream) shipped verbatim to the genuine validator: a persistent per-request HTTP 400 — exactly the failure class this PR exists to prevent. The pass now replaces such roots with {} mirroring sanitize_for_responses, while declared non-object schemas (string-enum roots) still ship verbatim. Pinned; mutation verified red.
  • MINOR (fixed): a stripped composition whose merge yields properties but whose root declared no type shipped typeless (old line forced type:"object"; this is a common MCP shape). The pass now restores the object type scoped to stripped compositions — a negative pin keeps typeless roots without compositions untouched. Mutation verified red.
  • MINOR (fixed): the explicit post-merge prune_dangling_required was load-bearing but unpinned — a single-branch oneOf collapses inside sanitize(), which spreads branch required groups after sanitize's own prune ran (probe-verified). Pinned with that exact shape; mutation verified red.
  • MINOR (fixed): PR-body Behavior bullet 1 claimed "for schemas with root compositions the wire matches the previous release line" — a differential harness proved it true for apply_patch/finance/single-element-allOf/nested compositions but false for compound schemas (composition + root enum/not/dependent*) and non-object-branch compositions; body rewritten with the precise scope. Also fixed: three stale CHANGELOG 0.9.1 test citations (rationale is under §0.9.0), the stale module doc, and the weak dependentSchemas is_some() assertions (now exact, well-formed fixtures).
  • MINOR (fixed): no root-allOf fixture existed (the keyword is in the recorded 400); a strip/merge/note fixture is now pinned.

Differential harness result (old sanitize_for_responses vs new pass)

input identical?
apply_patch / finance (real schemas) byte-identical incl. notes
single-element allOf, nested compositions identical
composition + root enum/not/dependent* intended divergence (kept verbatim now)
non-object-branch compositions intended divergence (no forced type:"object" / empty schema)
untyped composition root with property branches identical after the type-restore fix

Mutation ledger (this round)

All nine executed mutations red on their pins: gate-off, sanitize_for_responses swap, note-drop, endpoint-clause-drop, shared-sanitize()-anchor-drop, merge-skip (round-2 set, re-verified in round 2 and pins re-checked), plus this round's prune-removal, guard-removal, and injection-polarity flip (each red on exactly its pin; negative pins stay green).

Verified-sound notes

  • Performance strictly same-or-better: verbatim lanes drop both full-tree walks; the narrowing lane runs one full walk vs two at base; clone count unchanged; note generation is deterministic (fixed keyword order, stable sort) so prompt-cache keys don't flake; single per-request call site, no hot-loop exposure.
  • Local gates: fmt clean, 72 schema_sanitize + 42 client::anthropic tests green; clippy shows only pre-existing base items (byte-identical files); CI green on 698c41e and re-running on 78da100.
  • Accepted disclosures (unchanged by this round): degenerate declared schemas (e.g. a root oneOf of two string enums) now ship as their post-normalization self instead of a forced {type:"object",properties:{}}; verbatim lanes' third-party validators are undocumented territory by design; the gate keys on provider identity or endpoint, so a provider-Anthropic proxy override narrows (conservative, matches the previous release line) while a custom provider at the same gateway ships verbatim.

No-Issue: review round and follow-ups confined to the PR's own scope (records-grounded hardening of the same two files); the repository has issues disabled.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-4 fresh review — 12 lanes, from scratch (head 78da100; 3 MAJOR found)

Fourth from-scratch review; all prior-round conclusions treated as reference only, every finding below re-derived this round. Rebase first: origin/pinvou3-clean is unchanged at 11cba403dc and the branch merge-base equals its tip — rebase was a no-op; the branch is on the latest base. CI on 78da100: 13 pass + 1 environmental skip (sync, token-gated by design); MERGEABLE/CLEAN. Twelve parallel lanes: claims-vs-implementation, scope/smuggling, receipts (re-fetched live today), sanitizer deep-logic, gate/routing, test quality, executed mutations, performance, elegance/conventions, executed differential harness, verbatim-lane risk sweep (web archaeology), and meta/merge-train.

The four review questions

1. Actually useful, root-caused, complete — YES for the recorded scope, with one completeness gap (M1).

  • Receipts re-verified firsthand today (2026-11-07): f8e7573 quotes the live 400 verbatim (keyword set exactly oneOf/allOf/anyOf); CHANGELOG §0.9.0 records the same rationale; claude-code#4886/#30212/#84056 confirmed via API (closed not-planned), #95504 confirmed open (note: that one is Claude Code's client-side MCP-tool dropping, not a server 400 — the body labels it correctly, it's just the weakest leg of the empirical chain); upstream codewhale-hq/Codewhale d29abf3 (2026-11-03) still routes the lane through sanitize_for_responses via RouteToolProjection::Relaxed; DeepSeek "input_schema: Fully Supported", MiniMax and Model Studio doc states re-fetched and confirmed today.
  • History archaeology supports the premise: the flatten was born Anthropic-adapter-only (f8e7573, 2026-07-11, quoting the live genuine-endpoint 400) and later Messages lanes inherited it silently via the shared builder (47163da, 2026-07-13, makes no schema claim). No commit or CHANGELOG entry anywhere records a non-Anthropic provider rejecting root unions. Over-application-by-file-sharing is exactly what this PR fixes.
  • Gate completeness re-verified: build_anthropic_body has exactly one production caller (client.rs prepare_outbound_request); streaming/non-streaming/preview all consume prepared.body; no Messages lane still flattens (remaining sanitize_for_responses sites are Responses/xAI-chat/Kimi-chat); OpenAI-wire providers cannot reach this builder, so the endpoint clause cannot narrow an OpenAI-wire body; gate and transport read the same base_url (ANTHROPIC_BASE_URL folds into it via the config env layer); no .anthropic.com suffix bypass (api.anthropic.com.evil.com → false); loopback placeholder keeps gate and destination consistent.

2. Elegant — YES. A strictness-enum parameterization of sanitize_for_responses was re-assessed and correctly rejected (orthogonal keyword sets + non-decomposable behavior differences; ~10 genuinely new lines on top of fully shared helpers — reuse happened where it pays, matching the file's one-named-pass-per-lane precedent and the delegation style of sanitize_for_xai_parameters). Placement, pub surface, naming symmetry, doc comment (scope + grounding + carve-outs, all cross-checked accurate), DCO, and conventional-commit format all conform. Receipt triple restated at ~6 sites is the only maintenance-burden note (house style, NIT).

3. Smuggled changes — NONE. The diff is 5 hunks in exactly the two declared files; every hunk classifies as required-by-purpose or supporting pin. sanitize_for_responses, sanitize, and all shared helpers verified byte-identical to base; no lint/config/changelog/lockfile churn; no trailing-whitespace/line-ending noise; secrets scan clean. Per-commit coherence checked (each commit standalone-consistent; intermediate commit 1 ships the disclosed known-failing verbatim-everywhere state — moot under squash merge).

4. Bug-free — not yet: 3 MAJOR.

  • MAJOR-1 (code, narrowing lane): the pass's own compensation machinery has a hole for property-less compositions. For a root oneOf/anyOf/allOf whose branches carry no properties — allOf of $refs (the classic OpenAPI→MCP shape), required-only branches, boolean branches, {"type":["string","null"]} unions — nothing is merged, and type restoration requires non-empty merged properties (schema_sanitize.rs:179-183), so the wire gets a constraint-free, usually typeless root with typically no note (no required groups to name; harness-executed: allOf+$defs/$ref → {$schema,$defs,additionalProperties} typeless). The old line at least kept a {type:"object",properties:{}} floor, and the pass's own doc states the restoration rationale — "the model-facing contract otherwise loses its object shape" — which applies verbatim to this case. Suggested fix: restore the type whenever strips_composition && !contains_key("type") (drop the merged_properties conjunct; the round-3 negative pin — typeless roots without compositions stay untouched — still holds), and consider a generic note when a stripped composition merges nothing.
  • MAJOR-2 (two pin gaps, both mutation-confirmed this round): (a) the identity disjunct of the gate is unpinned — every narrowing test constructs a client whose base_url resolves to api.anthropic.com, so deleting matches!(self.api_provider, ApiProvider::Anthropic) leaves the whole suite green; the ANTHROPIC_BASE_URL-proxy scenario the call-site comment itself gives as the reason identity stays in the gate has no test. One anthropic_test_client(Some("https://proxy.example.org")) + root_union_tool + note-assertion closes it. (b) The root-only strip scope is asserted nowhere: no new fixture carries a nested composition keyword, so widening the strip tree-wide survives everything; the sibling Responses pass pins exactly this (responses_sanitize_preserves_nested_alternatives). One fixture with properties.x.oneOf asserted intact closes it.
  • MAJOR-3 (decision + disclosure, verbatim posture on Claude-family gateway lanes): the recorded rejection applies transitively to lanes that front genuine Anthropic validators behind a gateway: custom wire="anthropic" providers at third-party gateways, and Openmodel / OpencodeZen rows advertising claude-* models. There is a live incident of exactly this shape (recursive#15 case B: client → one-api-style gateway → Claude upstream → root anyOf → 400 upstream_error; previously flattened-and-worked). Because apply_patch (root oneOf) and finance (root anyOf) ship every turn, a rejecting upstream breaks every tool-bearing request on such a lane — the same total-failure mode this PR exists to prevent on the first-party lane. These lanes currently get neither evidence nor a pin, and the body's fork-vs-upstream justification ("lanes whose providers document full support or no restrictions") does not cover undocumented gateways. Options: (a) extend narrowing to every wire="anthropic" lane — a narrowed schema is valid for any upstream, so this only costs expressiveness, never validity; or (b) keep verbatim but disclose the gateway-fronting-Claude regression class explicitly in the body and accept it. This is a disclosed-at-policy-level trade-off, so it needs an author decision, not a forced direction.

Executed mutation ledger (this round, dedicated worktree)

All 8 body-claimed mutations re-executed and red exactly as claimed: gate-off (C2+C4), sanitize_for_responses swap (C3 only, as stated), drop shared sanitize() (S8+S6), drop endpoint clause (C4), note-order reversal (C2+C4), prune removal (S6), non-object-root removal (S5), type-restoration removal (S3+S7 with S4 negative staying green). Two new mutations: note-threshold >=2→>=1 red as predicted; merge precedence or_insert→insert SURVIVED (no test has a root/branch same-named property) — see MINOR list.

Differential harness result (executed: exact base pipeline vs head pipeline)

input OLD vs NEW
real apply_patch, real finance, fetch_url, automation, 6 of 7 child-catalog tools, MCP root-oneOf, anyOf object|null byte-identical (value + serialized bytes + note)
agent tool (root dependentSchemas) intended divergence (dependent keywords now ship verbatim, old dependent-note dropped)
root allOf of $refs divergence — NEW typeless root (MAJOR-1)
anyOf string|null intended divergence (typed root preserved instead of fake object)
colliding-property oneOf value-equal, byte-differs only in key order (remove swap_remove ordering)
enum-root + dependentRequired intended divergence (verbatim)

The body's byte-identity claim for apply_patch/finance is confirmed empirically; every observed divergence traces to the documented narrowing scope; note output byte-stable across repeat runs (prompt-cache safe). Caveat: the harness is not committed — a regression pin for the two real schemas would be cheap and valuable (MINOR).

MINOR / advisory

  1. Openmodel, the OpencodeZen Message route, and wire-flipped built-ins are claimed verbatim in the body but tested nowhere (loop pins DeepSeek/MiniMax/ModelStudio only) — all three route through the same builder, so extending the loop is cheap.
  2. The verbatim-lane fixture is a sanitize() no-op, so the test pins "no flatten" but not "ships exactly as declared": a mutation running any normalization on the else-branch survives. One dirty fixture (Pydantic nullable union) asserted byte-identical closes it.
  3. Merge precedence (root property vs same-named branch property, first-wins) is unpinned — executed mutation survived.
  4. The note is computed pre-sanitize(), which prunes dangling branch required — a note can name a parameter that no longer exists anywhere (inherited order; old pass identical).
  5. A root carrying two compositions surfaces only the first keyword's note; the second dropped constraint is silent (unpinned, unmentioned — pin the precedence or note the limitation).
  6. The apply_patch/finance byte-identity differential is not a committed test (verified analytically + executed this round; commit a pin).
  7. The DeepSeek receipt "Fully Supported" overstates what those docs guarantee (their endpoint has recorded 400s/422s on other schema keywords: regex escapes, type:"custom"); "documents full support; no recorded rejection" is the precise wording.
  8. Squash-merge concatenates all four commit bodies: commit 1's retracted no-rejection premise and commit 3's stale §0.9.1 cite land in permanent history — edit the squash message at merge time.
  9. The call-site comment's "noting the dropped constraint" overstates the real wire for apply_patch/finance: the registry pre-pass prunes each branch's required (branches declare no properties), so those two built-ins ship noteless in both lines (parity — but the wording implies otherwise).
  10. Merge-train advisory: sibling fix(tui): hold Moonshot tool schemas to the MFJS docs #143 changes root_composition_constraint_note (single-group labels), which alters this lane's note output post-merge depending on order — reconcile fixtures then; fix(tui): hold DeepSeek strict shaping to documented rules #147 has a guaranteed textual conflict at the shared schema_sanitize.rs anchor (queue rebases handle both; no action in this PR).

Verified sound (no action)

Performance strictly improves (narrow lane 2 full walks → 1; verbatim lanes 2 → 0; clone profile unchanged; no new recursion; 1000-branch merge is linear; deterministic note/merge ordering via fixed keyword array + sort+dedup + preserve_order maps). No reachable panics; recursion depth parity with base; idempotent with request rebuilds from a pristine catalog. Case matrix (native+default / native+proxy / native+non-enforcing gateway / custom+genuine / custom+gateway / OpenAI-wire-at-anthropic.com) all classify correctly under the gate. No i18n duty (note is model-facing wire copy, matching the existing Responses-pass note). Test hygiene clean (no env races, no ignored tests, assert-message quality high).

Verdict: 0 BLOCKER / 3 MAJOR / ~9 MINOR. The three MAJORs are small, well-scoped fixes squarely inside the PR's own direction (a two-line condition extension, two one-fixture pins, one disclosure/decision); happy to re-verify a fix wave.

No-Issue: review round confined to the PR's own scope (records-grounded hardening of the same two files); the repository has issues disabled.

Round-4 review fix wave:

- A root composition whose branches carry no properties (an allOf of
  $refs, required-only or boolean branches) merged nothing, and type
  restoration required non-empty merged properties, so the narrowing
  lane shipped a constraint-free typeless schema with usually no note —
  the model-facing contract lost its object shape, which the pass's own
  doc names as the reason restoration exists. The object type is now
  restored for every stripped composition, and when the merge
  contributes no properties the dropped constraint is surfaced as a
  generic keyword-specific note. Pinned (allOf-of-$refs and boolean
  branches).
- Two pin gaps closed: the gate's identity disjunct (an
  Anthropic-identity client at a proxy base URL asserted to narrow) and
  the root-only strip scope (a nested oneOf under properties asserted
  to survive).
- Pins for the remaining round-4 findings: verbatim lanes ship a schema
  the registry would normalize byte-identically (no normalization on
  the else branch), root properties win over same-named branch
  properties, the note keyword precedence is pinned, Openmodel joins
  the verbatim lane loop, and apply_patch/finance byte-identity with
  the previous release line is now a committed test.

Signed-off-by: asto <asto18089@126.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-4 fix wave pushed — fd2d309

All round-4 MAJORs fixed plus the actionable MINORs; every new pin mutation-verified. 120/120 across client::anthropic + tools::schema_sanitize locally; CI running on the new head.

MAJOR-1 — object floor for property-less compositions (fixed)

A root composition whose branches carry no properties (an allOf of $refs — the classic OpenAPI-to-MCP shape — required-only or boolean branches) merged nothing, and type restoration required non-empty merged properties, so the narrowing lane shipped a constraint-free typeless schema with usually no note. The object type is now restored for every stripped composition (the merged_properties conjunct is gone; the round-3 negative pin — typeless roots without compositions stay untouched — still holds), and when the merge contributes no properties the dropped constraint is surfaced as a generic keyword-specific note (oneOf/anyOf/allOf each get exact text, fixed order, deterministic). Pinned by anthropic_messages_sanitize_restores_the_object_floor_for_property_less_compositions.

MAJOR-2 — both pin gaps closed

  • Identity disjunct: anthropic_identity_narrows_even_on_a_proxy_base_url constructs an ApiProvider::Anthropic client at https://proxy.example.org and asserts narrowing + note — the ANTHROPIC_BASE_URL-proxy scenario the gate keeps identity for.
  • Root-only strip scope: anthropic_messages_sanitize_preserves_nested_compositions pins a nested oneOf under properties.payload surviving verbatim (mirroring the sibling Responses-pass negative pin).

MAJOR-3 — gateway-fronting-Claude class (decision: disclose)

Adopted the disclosure option rather than narrowing all wire="anthropic" lanes: the body now carries a "Known limitation" section covering the transitive-genuine-Claude gateway class (custom providers at gateways, Openmodel/Zen claude rows), the every-turn apply_patch/finance blast radius, the doc-silence epistemics (DeepSeek's own endpoint rejects other schema keywords), and the self-contained follow-up (extend narrowing to every wire="anthropic" lane) if it bites in practice. Narrowing every lane now would re-alter contracts on providers that genuinely support unions — the exact failure mode this PR removes.

MINOR pins added (each mutation red on exactly its pin)

pin mutation that kills it
anthropic_messages_pass_matches_the_previous_release_line_on_the_recorded_tools — real apply_patch/finance through both pipelines (registry pre-pass included; Value + serialized bytes + note) byte-identity regressions on the headline claim
non_anthropic_messages_lane_ships_a_schema_the_registry_would_normalize (bare object byte-identical on DeepSeek lane) any normalization added to the verbatim else-branch
anthropic_messages_sanitize_keeps_root_properties_over_same_named_branch_properties or_insert → last-wins (and_modify)
anthropic_messages_sanitize_notes_only_the_first_composition_keyword note keyword order swap
Openmodel added to the verbatim lane loop (now four lanes pinned) adding ApiProvider::Openmodel to the gate

Mutation ledger for the fix wave (all executed, each red on exactly its target pin unless noted): (1) restoring the merged_properties conjunct → object-floor test; (2) flipping the generic-note condition → object-floor + merge + previous-release-line tests (polarity flip, collateral expected); (3) tree-wide strip → nested-composition pin; (4) dropping the identity clause → proxy-base-URL test only (all endpoint-clause tests stay green, proving the gap was real); (5) normalizing the verbatim else-branch → registry-would-normalize test only; (6) last-wins merge → root-property pin only; (7) note keyword swap → dual-composition pin only.

Not adopted (with reasons)

  • Ghost-name note (note computed pre-sanitize() can name a pruned parameter): inherited from the Responses pass unchanged; moving the note post-sanitize() would un-describe collapsed nullable unions, which round 3 deliberately pinned. Parity with the previous line.
  • Merge first-wins narrowing a declared union / unnoted branch additionalProperties:false: shared-helper behavior identical to the Responses lane; changing merge semantics here would silently diverge the two lanes. The new precedence pin documents the behavior instead.
  • Dual-composition second note: precedence now pinned instead; a second note would double model-facing copy for a rare shape.
  • Squash-message cleanup (commit 1's retracted premise): merge-time action, not a code change.

No-Issue: fix wave confined to the PR's own two files and the review findings; the repository has issues disabled.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-5 fresh review — 8 lanes, from scratch (head fd2d309e0; 0 BLOCKER / 0 MAJOR / 9 MINOR)

Fifth from-scratch review; all prior-round conclusions treated as reference only, every finding below re-derived this round.

Rebase first: origin/pinvou3-clean unchanged at 11cba403dc; the branch merge-base equals its tip — rebase was a no-op; the branch sits on the latest base. No parent-PR stack applies (#137 is an independent stack; the body's no-conflict-surface claim verified — only crates/tui/src/client/anthropic.rs and crates/tui/src/tools/schema_sanitize.rs change). CI on fd2d309e0: 13/13 green, MERGEABLE/CLEAN.

Round-4 fix verification: all three round-4 MAJORs landed and hold under fresh eyes:

  • MAJOR-1 (property-less compositions lost the object floor): fixed — restoration no longer requires non-empty merged properties (schema_sanitize.rs:188-190), pinned by anthropic_messages_sanitize_restores_the_object_floor_for_property_less_compositions (schema_sanitize.rs:1387; allOf-of-$refs, boolean branches) and mutation-red.
  • MAJOR-2 (two pin gaps): fixed — anthropic_identity_narrows_even_on_a_proxy_base_url (anthropic.rs:1380) pins the identity disjunct; anthropic_messages_sanitize_preserves_nested_compositions (schema_sanitize.rs:1447) pins the root-only strip scope. Both mutation-red.
  • MAJOR-3 (gateway-fronting-Claude disclosure): resolved via the body's "Known limitation" section — the regression class is explicitly disclosed with the self-contained follow-up declared.

Lanes this round (8, parallel): claims-vs-implementation audit; sanitizer deep-logic (14 empirical repros in an isolated harness built from the extracted functions); gate/routing enumeration (every Messages lane resolved from config/client/route code); receipts — every external record re-fetched live 2026-11-08 (upstream PR codewhale-hq#4346, d29abf312 RouteToolProjection::Relaxed, claude-code codewhale-hq#4886/#30212/#84056/#95504, Anthropic tool-use + structured-outputs docs, DeepSeek "Fully Supported", MiniMax, Model Studio); scope/smuggling/DCO; local gates (own target dirs); executed mutations (dedicated worktree); performance; repo conventions.

Claims audit — everything auditable verified TRUE

The headline equality claim is real: anthropic_messages_pass_matches_the_previous_release_line_on_the_recorded_tools runs the actual ApplyPatchTool::input_schema() and FinanceTool::new().input_schema() through the production registry order (sanitize → canonicalize_schema, registry.rs:278-279) plus each adapter pass, asserting Value + serialized-bytes + note equality. Both tools are model-visible registered built-ins with the claimed root compositions (apply_patch.rs:372 root oneOf; finance.rs:220 root anyOf). "MCP schemas carry no registry sanitization upstream" verified (mcp.rs:4065-4080 raw clone; build_anthropic_body reached via the single client.rs:2222 dispatch). "Verbatim else runs no normalization" pinned by the bare-object byte-identity test. strict/cache_control passthrough byte-identical on both branches. The tool enumeration is complete for root unions: a full sweep of all 86 registered tool schema builders found only apply_patch/finance (model-visible) plus a hidden v0.9.x-compat Bash alias (model_visible() -> false, correctly omitted). Lane table verified from code: native-Anthropic (+ANTHROPIC_BASE_URL proxies) narrows; DeepSeek/MiniMax/Model-Studio/Openmodel/OpencodeZen/custom-gateway verbatim; dual-wire built-ins verbatim unless pointed at *.anthropic.com — exactly as the body states, with no mislabeled lane. External receipts: f8e7573 quotes the live 400 verbatim (2026-07-11, upstream PR codewhale-hq#4346 confirmed); CHANGELOG §0.9.0 records it; upstream d29abf312 (2026-11-03) still runs sanitize_for_responses on its Messages lane; all four claude-code issues exist with the stated dates/close-states; DeepSeek's page says input_schema | Fully Supported; MiniMax "Fully supported"; Model Studio is true doc-silence (weakest grounding, as the body's limitation section implicitly treats it).

Findings

MINOR-1 (disclosure completeness): the default-active agent tool carries a root dependentSchemas tree (tools/subagent/mod.rs:9622), making it a third model-visible built-in whose first-party wire shape this PR changes (the old pass stripped dependent keywords tree-wide; the narrow pass ships them verbatim by design). The body names only apply_patch/finance as affected built-ins and describes the dependent-keyword class generically; one body sentence closes the gap. (It is also the concrete rider on NOTE-1 below.)

MINOR-2 (endpoint-predicate edge spellings): is_native_anthropic_base_url (anthropic.rs:652-663, pre-existing, now load-bearing for schemas) misses HTTPS://api.anthropic.com (case-sensitive scheme strip), https://api.anthropic.com. (trailing-dot FQDN), and //api.anthropic.com (protocol-relative) — those spellings of the genuine lane ship raw root unions, the exact recorded 400. The ApiProvider::Anthropic identity clause covers the normal config path, so this bites only unusual spellings; one-line normalization (lowercase before strip, trim trailing dot) closes it. The reverse userinfo edge (https://api.anthropic.com:8443@evil.com) only ever mis-narrows — conservative direction, no action needed.

MINOR-3 (typeless escape of the object floor): a single-branch required-only union {"oneOf":[{"required":["a"]}]} is consumed by sanitize's collapse_single_element_unions (schema_sanitize.rs:334-352) before the strip check (:176), so neither the type floor (:188) nor the generic note fires — the wire gets {} where the old line shipped {"type":"object","properties":{}} (both lines lose the constraint; the new line additionally loses the type). Real-world shape (Pydantic/MCP single-element unions), still schema-valid, but it contradicts the pass's stated restoration intent on this route and nothing pins it. Either extend the floor to sanitize-collapsed single-element unions or pin current behavior with a test.

MINOR-4 (multi-composition blind spot): a root carrying both oneOf and allOf strips both, but the note machinery (.find at :176, root_composition_constraint_note at :462) reports only the first keyword — an allOf conjunction is silently dropped from the model's view, and the generic fallback inherits the same single-keyword blind spot (:192-199). Pre-existing machinery shared with the Responses pass, but the new fallback could compose both notes; no test covers oneOf+allOf (the committed precedence test covers oneOf+anyOf only).

MINOR-5 (sanitize-hoisted compositions stripped silently): sanitize's collapse can hoist a nested multi-branch union to the root ({"anyOf":[{"oneOf":[A,B]}]} → root oneOf), which the wrapper then strips with properties merged and — because merged_properties is non-empty — no note of any kind (:192-199 filter). The constraint is silently erased. Same silent loss existed on the old line (pre-existing machinery), but the new generic-note machinery could cover this path.

MINOR-6 (note can name ghost properties): the constraint note is computed pre-sanitize (:162-164), so a branch required entry naming a property that sanitize later prunes ships in the note text (e.g. "…must be provided: a + ghost | b" for a pruned ghost). Rare input; misleading model-facing copy only.

MINOR-7 (comment made stale by this PR): tools/subagent/tests.rs:6053-6055 still reads "Anthropic Messages and OpenAI Responses (and xAI, an alias of the Responses pass) share sanitize_for_responses" — false for the Anthropic Messages half after this PR. One-line comment fix in a third file; fix in-wave or defer disclosed.

MINOR-8 (latent non-idempotence of the new pass): {"oneOf":[true,false]} → first run {"type":"object"} + generic note; feeding that output through the pass again yields {"type":"object","properties":{}} with no note (sanitize's bare-object injection runs before the floor insert, so a floor-created bare object escapes its own injection on re-entry). No caller re-feeds sanitized output today — latent only; worth a doc-comment line if the pass is ever reused beyond this adapter.

MINOR-9 (two unpinned note-machinery behaviors, mutation-proven escapes this round): (a) swapping the ["oneOf","anyOf","allOf"] find order (schema_sanitize.rs:176) leaves the entire suite green — no fixture carries a root with two property-less compositions, so the generic-note keyword choice is unpinned (under a mutated order, a property-less oneOf+allOf root would report allOf's constraint); (b) dropping !properties.is_empty() from merged_properties (schema_sanitize.rs:184-187) also escapes — no fixture has a present-but-empty properties beside a composition, so the generic-note suppression condition is unpinned. One fixture each closes both; both compound MINOR-4's multi-composition blind spot.

NOTE-1 (the residual risk, disclosed): the first-party lane now ships root enum/not, dependent keywords, and declared non-object roots verbatim where the old line stripped/forced them, and the default-active agent tool rides this every turn (MINOR-1). Records support the narrowing — 14 months of claude-code reports name only root compositions; the strict-mode docs affirmatively support anyOf/allOf — but no live probe validates acceptance of the unrecorded keywords. A one-request probe carrying the agent tool on api.anthropic.com would close the loop decisively and cheaply; on any failure the declared follow-up (narrow every wire="anthropic" lane) is self-contained.

NOTE-2 (clippy drift, pre-existing): local clippy 1.99 -D warnings fails with 2 pre-existing collapsible_if errors (core/engine/turn_loop.rs:5611, tools/subagent/mod.rs:8058) — byte-identical at base 11cba403dc, zero diagnostics in the PR's files; CI's pinned toolchain stays green. Base debt, not this PR's.

Mutation ledger (this round, dedicated worktree, clean revert after each)

Mutation Site Named pin Result
gate → false anthropic.rs:117-118 anthropic_lane_narrows_the_recorded_root_composition_keywords RED
narrow pass → sanitize_for_responses anthropic.rs:126 anthropic_lane_keeps_unrecorded_keywords_verbatim RED
drop sanitize(schema) call schema_sanitize.rs:166 anthropic_messages_sanitize_keeps_the_shared_registry_normalization RED
gate drops endpoint clause anthropic.rs:117-118 custom_anthropic_wire_clients_narrow_by_first_party_endpoint RED
type floor gated on merged_properties schema_sanitize.rs:188 anthropic_messages_sanitize_restores_the_object_floor_for_property_less_compositions RED
verbatim branch runs narrow pass anthropic.rs:135-137 non_anthropic_messages_lane_ships_a_schema_the_registry_would_normalize RED
note join "\n\n" → "\n" (adversarial) anthropic.rs:130 proxy + narrowing tests (exact-eq) CAUGHT
swap find order (adversarial) schema_sanitize.rs:176 — ESCAPED → MINOR-9
prune before merge (adversarial) schema_sanitize.rs:191 — behavior-neutral (dead mutation, provably equivalent)
drop !properties.is_empty() (adversarial) schema_sanitize.rs:184-187 — ESCAPED → MINOR-9

All six PR-claimed pins bite — genuine test failures, not compile errors. The two escape claims rest on caller-completeness evidence (both call sites of the pass enumerated; the mutated binary run over the complete caller set — client::anthropic:: + schema_sanitize:: + dialect_seam, 130 tests — stays green), not a completed full-suite run.

Local gates (own target dirs; two independent dirs agreed on clippy)

  • cargo fmt --all -- --check — PASS
  • cargo test -p codewhale-tui --lib scoped: anthropic_messages 17/17, schema_sanitize 77/77 (full module 76/76 + 1 name overlap), client::anthropic 44/44 — all PASS, no cross-test interference
  • dead-code budget 397/420, locale parity 14×2101/2101, provider registry / crate boundaries / command manifest / README translations — all PASS
  • clippy: only the 2 pre-existing base errors above (see NOTE-2)

Convention compliance

CHANGELOG absence is per policy — CONTRIBUTING.md:191-197 mandates changelog entries written on main at merge time ("A PR that carries changelog hunks will be asked to strip them"), confirmed by recent merged PRs (#131/#133/#134/#135) and by the f8e7573 precedent (entry landed a day later via the release-manager commit). Tests colocated per CONTRIBUTING.md:49-52, matching sibling adapter practice (ab6c68a). Fork-register deferral to the parent gitlink-advance is exactly what the parent fork-policy.md §3/§4 prescribes; no CodeWhale-side artifacts required.

Review-criteria summary

  1. Useful + complete + root-caused: yes — the unconditional flatten (itself record-triggered for only three keywords, extrapolated beyond them per the receipts) is replaced by record-scoped narrowing on the lane with the recorded rejection; every claim audited maps to committed code/tests; the round-4 gaps are closed and pinned.
  2. Elegant: shared helper layer reused (sanitize, merge_root_composition_properties, root_composition_constraint_note, prune_dangling_required); the new pass is a thin orchestrator; honest duplication guarded by the cross-pass byte-equality test; no wheel reinvention.
  3. Smuggling: CLEAN — exactly 2 files, deletions exactly the old flatten block + one doc line, DCO 5/5, every added line described in commit messages, no weakened pre-existing tests, no tautological pins.
  4. Bug-free: no blocker/major found across the eight lanes including 14 empirical sanitizer repros; the nine MINORs are edge-shape, model-facing-copy or disclosure items, none affecting the recorded tools or the common path.

Verdict: 0 BLOCKER / 0 MAJOR / 9 MINOR / 2 NOTE — approve direction. MINOR-1 (one body sentence) and MINOR-3 (one pin) are the two I'd most want before merge; MINOR-9 is two one-fixture pins if you want the note machinery fully pinned in-wave; the rest are optional follow-up material.

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.

1 participant