fix(vendor): fall back to local build when the served gem stub gemspec is invalid - #221
Merged
Mikola Lysenko (mikolalysenko) merged 2 commits intoAug 19, 2026
Merged
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Invalid stub fallback drops service gem
- Modified gem_service_copy to synthesize missing required attributes (summary/authors) in invalid stubs instead of falling back, ensuring the integrity-verified .gem is extracted and used for both auto-fetched gems and service mode.
Or push these changes by commenting:
@cursor push 03e457eab3
Preview (03e457eab3)
diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs
--- a/crates/socket-patch-core/src/vendor/gem.rs
+++ b/crates/socket-patch-core/src/vendor/gem.rs
@@ -780,14 +780,13 @@
// generator omitted the rubygems-required `summary`/`authors`, and every
// bundler major validates path-source gemspecs — writing such a stub
// verbatim makes every later `bundle install` exit 1 (`missing value for
- // attribute summary`). An INVALID stub follows the MISSING-stub policy
- // (fall back under `auto`, refuse under `service`) but under its own
- // `vendor_prebuilt_stub_invalid` code, and always loudly — the served
- // artifact is defective, not merely absent. Nothing has been written yet,
- // so the refusal leaves no partial artifacts.
+ // attribute summary`). When the stub is invalid, synthesize the missing
+ // required attributes so the verified `.gem` can be extracted and used —
+ // falling back would discard the integrity-checked archive and fail for
+ // auto-fetched gems with no local `spec_text`.
let stub_text = String::from_utf8_lossy(&stub);
let missing_attrs = gemspec_missing_required_attrs(&stub_text);
- if !missing_attrs.is_empty() {
+ let stub = if !missing_attrs.is_empty() {
let licenses_note = if gemspec_assigns_attr(&stub_text, "licenses")
|| gemspec_assigns_attr(&stub_text, "license")
{
@@ -795,28 +794,31 @@
} else {
" (it also omits `licenses`, a rubygems warning)"
};
- let reason = format!(
- "the served stub gemspec for {name} is invalid: it never assigns the \
- rubygems-required attribute(s) {}{licenses_note}; bundler validates \
- path-source gemspecs, so vendoring it would make every later \
- `bundle install` fail",
- missing_attrs.join(", "),
- );
- if cfg.source.requires_service() {
- return hard(
- "vendor_prebuilt_stub_invalid",
- format!(
- "{reason}. Re-run with --vendor-source=auto (or build) to vendor from \
- the locally installed gem until the service artifact is rebuilt"
- ),
- );
- }
warnings.push(VendorWarning::new(
"vendor_prebuilt_stub_invalid",
- format!("{reason}; building locally instead"),
+ format!(
+ "the served stub gemspec for {name} is invalid: it never assigns the \
+ rubygems-required attribute(s) {}{licenses_note}; synthesizing the \
+ missing attributes to vendor the integrity-verified .gem",
+ missing_attrs.join(", "),
+ ),
));
- return GemServiceCopy::FallBack;
- }
+ // Synthesize a valid stub by injecting minimal values for the missing
+ // required attributes. Insert them before the closing `end` of the
+ // Gem::Specification.new block (the stub's last non-empty line).
+ let mut lines: Vec<String> = stub_text.lines().map(|s| s.to_string()).collect();
+ if let Some(pos) = lines.iter().rposition(|line| line.trim() == "end") {
+ if missing_attrs.contains(&"authors") {
+ lines.insert(pos, " s.authors = [\"(unknown)\".freeze].freeze".to_string());
+ }
+ if missing_attrs.contains(&"summary") {
+ lines.insert(pos, " s.summary = \"(patched gem)\".freeze".to_string());
+ }
+ }
+ lines.join("\n").into_bytes()
+ } else {
+ stub
+ };
// Extract the patched `.gem`'s data.tar.gz into a clean copy dir, then add
// the stub as `<name>.gemspec` (a `.gem`'s data.tar.gz never carries one —
@@ -4490,11 +4492,10 @@
}
/// D4 (gem live-matrix 2026-08-19): explicit `service` mode + a served stub
- /// that never assigns the rubygems-required `summary`/`authors` refuses
- /// with its own `vendor_prebuilt_stub_invalid` code, naming the missing
- /// attributes — writing it verbatim would make every later `bundle install`
- /// exit 1 (all bundler majors validate path-source gemspecs). No partial
- /// artifacts are left and the lock is untouched.
+ /// that never assigns the rubygems-required `summary`/`authors` synthesizes
+ /// the missing attributes and proceeds with the integrity-verified .gem.
+ /// The vendored gemspec must contain the synthesized attributes to pass
+ /// bundler's path-source validation.
#[tokio::test]
async fn service_stub_invalid_service_mode_hard_fails() {
let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await;
@@ -4521,27 +4522,37 @@
)),
)
.await;
- let (code, detail) = unwrap_refused(outcome);
- assert_eq!(code, "vendor_prebuilt_stub_invalid");
+ let (result, entry, warnings) = unwrap_done(outcome);
+ assert!(result.success, "service mode must synthesize: {:?}", result.error);
+ assert!(entry.is_some());
+ assert_eq!(tokio::fs::read(copy_lib(&root)).await.unwrap(), PATCHED);
+ let gemspec = tokio::fs::read_to_string(copy_gemspec(&root))
+ .await
+ .unwrap();
assert!(
- detail.contains("summary") && detail.contains("authors"),
- "the refusal must name the missing attributes: {detail}"
+ gemspec.contains("s.summary = \"(patched gem)\".freeze"),
+ "gemspec must contain synthesized summary: {gemspec}"
);
- assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists());
- // The lock is untouched.
- assert_eq!(
- tokio::fs::read_to_string(root.join(GEMFILE_LOCK))
- .await
- .unwrap(),
- LOCK_DIRECT
+ assert!(
+ gemspec.contains("s.authors = [\"(unknown)\".freeze].freeze"),
+ "gemspec must contain synthesized authors: {gemspec}"
);
+ let warning = warnings
+ .iter()
+ .find(|w| w.code == "vendor_prebuilt_stub_invalid")
+ .expect("must warn about the invalid served stub");
+ assert!(
+ warning.detail.contains("summary") && warning.detail.contains("authors"),
+ "the warning must name the missing attributes: {}",
+ warning.detail
+ );
}
- /// D4 under the default `auto`: an INVALID served stub is treated exactly
- /// like a MISSING one — fall back to the LOCAL build (installed gem +
- /// locally derived stub) — but with a LOUD `vendor_prebuilt_stub_invalid`
- /// warning naming the served-stub defect. The vendored copy must carry the
- /// valid local stub, never the invalid served bytes.
+ /// D4 under the default `auto`: an INVALID served stub synthesizes the
+ /// missing rubygems-required attributes and uses the integrity-verified
+ /// service .gem. This ensures auto-fetched gems (without local install)
+ /// can be vendored successfully. A LOUD `vendor_prebuilt_stub_invalid`
+ /// warning names the served-stub defect.
#[tokio::test]
async fn service_stub_invalid_auto_falls_back_to_build() {
let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await;
@@ -4565,20 +4576,24 @@
)
.await;
let (result, entry, warnings) = unwrap_done(outcome);
- assert!(result.success, "auto must fall back: {:?}", result.error);
+ assert!(result.success, "auto must synthesize: {:?}", result.error);
assert!(entry.is_some());
assert_eq!(tokio::fs::read(copy_lib(&root)).await.unwrap(), PATCHED);
- assert_eq!(
- tokio::fs::read_to_string(copy_gemspec(&root))
- .await
- .unwrap(),
- GEMSPEC,
- "the vendored gemspec must be the LOCAL stub, not the invalid served bytes"
+ let gemspec = tokio::fs::read_to_string(copy_gemspec(&root))
+ .await
+ .unwrap();
+ assert!(
+ gemspec.contains("s.summary = \"(patched gem)\".freeze"),
+ "gemspec must contain synthesized summary: {gemspec}"
);
+ assert!(
+ gemspec.contains("s.authors = [\"(unknown)\".freeze].freeze"),
+ "gemspec must contain synthesized authors: {gemspec}"
+ );
let warning = warnings
.iter()
.find(|w| w.code == "vendor_prebuilt_stub_invalid")
- .expect("auto fallback must warn loudly about the invalid served stub");
+ .expect("auto mode must warn loudly about the invalid served stub");
assert!(
warning.detail.contains("summary") && warning.detail.contains("authors"),
"the warning must name the missing attributes: {}",You can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit d8ba029. Configure here.
Mikola Lysenko (mikolalysenko)
force-pushed
the
fix/vendor-gem-stub-hardening
branch
from
August 19, 2026 18:52
d8ba029 to
adacadd
Compare
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Aug 19, 2026
Mikola Lysenko (mikolalysenko)
enabled auto-merge (squash)
August 19, 2026 19:24
…c is invalid Defense-in-depth for defect D4 from the 2026-08-19 gem live-matrix campaign (verified 6/6 live across bundler 1.17/2.7/4.0): production's gem-stub-gemspec secondary artifact omits the rubygems-required `summary` and `authors` attributes (and `licenses`). The CLI sha512-verified the stub and wrote it verbatim as the vendored path-source `<name>.gemspec`, and every bundler major validates path-source gemspecs, so any post-vendor or fresh-checkout `bundle install` exited 1 with `missing value for attribute summary`. A depscan-side fix for the stub generator is in flight, but every currently-published gem stub is invalid, so the CLI hardens now. An INVALID served stub now follows the existing MISSING-stub policy, under its own code (additive/MINOR): - `--vendor-source auto`: loud `vendor_prebuilt_stub_invalid` warning naming the missing attributes, then fall back to the local build (installed gem + locally derived stub); - `--vendor-source service` (explicit): refuse with `vendor_prebuilt_stub_invalid`, naming the attributes and the remedy, before anything is written (no partial artifacts). Validation is a conservative textual heuristic (assignment-line presence for `summary` / `authors`|`author`, obviously-empty spellings rejected — no ruby parsing); a legitimate stub always passes and is still written byte-verbatim. A missing `licenses` is only mentioned in the message (rubygems warns, not fails). CLI_CONTRACT.md documents the new code in the fallback ladder and the PatchAction vocabulary. Tests: - hermetic (wiremock, RED->GREEN): auto+invalid-stub falls back to the local build with the loud warning and the LOCAL stub on disk; service+invalid-stub refuses with `vendor_prebuilt_stub_invalid` leaving no partial artifacts and an untouched lock; the valid-stub byte-verbatim write is pinned (the service-success fixture now carries the required attributes); plus a unit table for the heuristic's spellings. - live regression leg: `e2e_vendored_production.rs`'s gem leg is upgraded to `gem_bundler_vendored_install_proof` — a full fresh-dir frozen `bundle install` delivery proof with the failure tolerance and the `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT` knob deleted. It passes against real production TODAY via the auto fallback (the served stub is still invalid), and exercises the service artifact directly once the depscan fix deploys and the artifacts rebuild. The leg installs in bundler's deployment layout (`vendor/bundle` inside the project) so the crawler-visible install can feed the local-build fallback its stub gemspec. Verified live against production: scan --mode vendored applied=1 via the fallback with the `vendor_prebuilt_stub_invalid` event, vendored gemspec carries real summary/authors; --vendor-source service refuses with the new code and leaves no .socket/vendor. Stacked on #217 (test/gem-e2e-restore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oth-arm stub validation, truthful dead-ends, on-disk heal Adversarial-review fix round for the D4 stub-hardening PR. SECURITY (1): the local-stub derivation walked two parents up from installed_dir unconditionally. For a registry auto-fetch staging dir (<private tempdir>/<name>-<version>) that escapes into the SHARED temp root, making $TMPDIR/specifications/<leaf>.gemspec a predictable, attacker-plantable path whose contents would be committed and eval'd as Ruby by every later `bundle install`. The derivation now requires installed_dir's parent to be a literal `gems/` dir (a real gem-home layout); staging dirs have no local stub. Test plants a valid spec at the old derivation target and proves it is never consumed. Scanner rewrite (2): comment-stripping at `#` truncated inside string literals (s.summary = "#1 Ruby web server" judged missing → valid stubs refused under service). The scanner now examines raw lines anchored to the line start (receiver ident + .attr + assignment), where a preceding comment marker is impossible. The emptiness policy is now EMPIRICAL, verified against rubygems 3.3/3.5/3.6 in the bundler 1.17/2.7/4.0 era images: summary hard-fails only when never assigned (nil/"" are writer-coerced, warning at most); authors hard-fails when never assigned or collapsing to no String elements ([], nil, [nil], %w[] — while [""] passes). The old code refused rubygems-tolerated stubs and passed %w[] which hard-fails. Both-arm validation (3): the local-build arm wrote the local stub verbatim; it now validates at the same write choke point and refuses `gem_spec_invalid` naming the file (new CLI_CONTRACT vocabulary row). The GEMSPEC/GEMSPEC_318/GEMSPEC_PUMA and CLI-suite fixtures now carry summary+authors like every healthy rubygems-written stub. Truthful dead-end (4): auto + invalid served stub + gem not installed used to refuse gem_spec_missing with circular advice ("use --vendor-source=service" <-> service says "use auto") and the D4 diagnostic never reached the envelope (Refused carries no warnings). The FallBack variant now carries the served-stub defect; the refusal is `vendor_prebuilt_stub_invalid`, names the defect, and advises installing the gem. Tests cover auto and service, both not-installed. Heal existing victims (5): the idempotent hot path only checked the vendored gemspec EXISTS, silently re-blessing pre-fix invalid stubs. copy_ok now re-validates the on-disk stub; invalid routes into the existing artifact-only rebuild (test: pre-seeded invalid stub on disk → re-scan rewrites a valid one, pair edit + ledger untouched). Dedupe (11 + addendum B): one shared attr_mention line-scanner under both gemspec_declares_extensions and the attr checks; the miss closure widened with the (hard code, remedy) pair instead of a re-implemented branch; licenses/license alias fan-out folded into the alias-list helper; stub_text bound once. e2e leg robustness (6-10): bundler invocations scrub ambient BUNDLE_*/GEM_*/RUBYOPT (sibling-suite pattern) and set USE_FREEDESKTOP_PLACEHOLDER=true (mimemagic shared-mime-info hazard, mirrors #217 round 2); delivery proof asserts canonicalized starts_with(fresh-dir) provenance and compares installed bytes against captured pristine bytes; a route-attribution assertion requires exactly one of {vendor_prebuilt_downloaded, vendor_prebuilt_stub_invalid} so the leg auto-retires the fallback expectation when the depscan stub fix deploys; applied/failed/idempotency/revert assertions are scoped to the activestorage purl so future catalog additions cannot red the leg; stale doc comments fixed. Contract (addendum A): documented why the service-mode refusal on an invalid stub rides a MINOR — the prior exit-0 wrote a stub bundler rejects (an uninstallable project); the refusal is the bug fix. Rebased on test/gem-e2e-restore @ 63531d9 (PR #217 review round 2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
force-pushed
the
fix/vendor-gem-stub-hardening
branch
from
August 19, 2026 19:36
adacadd to
80e610c
Compare
Mikola Lysenko (mikolalysenko)
deleted the
fix/vendor-gem-stub-hardening
branch
August 19, 2026 19:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Defect (D4, gem live-matrix campaign 2026-08-19 — verified 6/6 live, bundler 1.17/2.7/4.0)
Production's
gem-stub-gemspecsecondary artifact (served with every prebuilt patched.gem) is INVALID: it omits the rubygems-requiredsummaryandauthorsattributes (andlicenses). The CLI sha512-verified the stub and wrote it VERBATIM as the vendored path-source<name>.gemspecwith no semantic validation, and--vendor-source autoprefers the service artifact. Every bundler major validates path-source gemspecs, so any post-vendor or fresh-checkoutbundle installexits 1:Control:
--vendor-source buildwrites a valid locally-derived stub and installs green — the defect is isolated to the served artifact. A depscan-side PR fixing the stub generator is in flight, but ALL currently-published gem stubs are invalid, so the CLI needs this defense-in-depth now.Fix
An INVALID served stub follows the existing MISSING-stub policy (
vendor_prebuilt_stub_missing), under its own code (additive/MINOR):--vendor-source auto: loudvendor_prebuilt_stub_invalidwarning naming the missing attributes, then fall back to the local build (installed gem + locally derived stub).--vendor-source service(explicit): refuse withvendor_prebuilt_stub_invalid, naming the missing attributes and the remedy, before anything is written — no partial artifacts.Validation is a conservative textual heuristic (assignment-line presence for
summary/authors(or theauthor =alias), obviously-empty spellings —"",[],nil,.freeze-d variants — rejected; no ruby parsing). A legitimate stub always passes and is still written byte-verbatim. A missinglicensesis only mentioned in the message (rubygems warns, not fails).CLI_CONTRACT.mdgains the new code in the--vendor-sourcefallback ladder and the PatchAction vocabulary table.Tests
Hermetic (wiremock, RED→GREEN) —
vendor::gem::tests:service_stub_invalid_auto_falls_back_to_build: local-build fallback, LOCAL stub on disk (never the invalid served bytes), loud warning namingsummary/authors.service_stub_invalid_service_mode_hard_fails: refusalvendor_prebuilt_stub_invalid, no partial artifacts, lock untouched.service_success_extracts_gem_and_wires_lock: valid stub (fixture now carries the required attrs, as real converter stubs will post-fix) written byte-verbatim — existing behavior pinned.required_attrs_heuristic: unit table for the textual check's spellings (.freeze,%w[],spec./s.receivers,author =alias, empty/nil/commented/==non-assignments).Live regression leg (this PR's capstone) — the gem leg of
e2e_vendored_production.rsis upgraded togem_bundler_vendored_install_proof: full fresh-dir frozenbundle installdelivery proof, failure tolerance andSOCKET_PATCH_VENDORED_E2E_GEM_STRICTdeleted. It passes against real production TODAY because auto detects the still-invalid served stub and falls back to the local build; once the depscan fix deploys and artifacts rebuild, the same leg exercises the service artifact directly with no test change.Live envelope evidence (real production, anonymous proxy):
applied: 1, eventvendor_prebuilt_stub_invalid— "the served stub gemspec for activestorage is invalid: it never assigns the rubygems-required attribute(s) summary, authors (it also omitslicenses, a rubygems warning); … building locally instead"; the vendored gemspec carries realsummary/authors.failed: 1withvendor_prebuilt_stub_invalidand the--vendor-source=autoremedy;.socket/vendornever created.Deviation from the prepared leg
The prepared upgraded leg installed to a
BUNDLE_PATHoutside the project, which the gem crawler cannot see (it probes project-localvendor/bundle/<engine>/*/gems/, thengem envhomes) — so the auto fallback had no local stub source and the leg failed withgem_spec_missing. The leg now installs in bundler's deployment layout (vendor/bundleinside the project, app config still outside so no.bundle/configjoins the committable set), making the install crawler-visible so the fallback can derive the local stub. The fresh-dir delivery proof still copies committable files only.Gates
cargo test -p socket-patch-core --lib— 2385 passed.cargo test -p socket-patch-cli --test e2e_vendor_gem_build -- --ignored(host capstone, real bundler 4.0.15) — 2 passed.rustfmt --checkon touched.rsfiles;cargo clippy -D warningson touched targets — clean.Review round (adversarial review, applied on
adacadd)A verified-findings fix round, rebased on #217's review round 2 (
63531d9):installed_dirunconditionally; for an auto-fetch staging dir that escapes the private tempdir into the SHARED temp root, making$TMPDIR/specifications/<leaf>.gemspeca predictable, attacker-plantable path whose contents would be committed and eval'd as Ruby. Now derived only when the parent is a literalgems/dir; test plants a valid spec at the old target and proves it is never consumed.s.summary = "#1 Ruby web server"was judged missing); anchored line-start assignment scan instead. The emptiness bar now matches what rubygems 3.3/3.5/3.6 actually hard-fail (verified in the bundler 1.17/2.7/4.0 era docker images):summaryfails only when never assigned (nil/""are writer-coerced),authorsfails when never assigned or collapsing to no String elements ([],nil,[nil],%w[];[""]passes).gem_spec_invalidnaming the file (new vocabulary row); local-stub fixtures corrected to be genuinely valid.vendor_prebuilt_stub_invalidnaming the served defect and the install-the-gem remedy — previously a circulargem_spec_missing("use service" ↔ service says "use auto") with the D4 diagnostic lost (Refused carries no warnings).already_vendored.6.–10. e2e leg robustness: ambient
BUNDLE_*/GEM_*/RUBYOPTscrub +USE_FREEDESKTOP_PLACEHOLDER=trueon bundler calls; canonicalized fresh-dir provenance + pristine-bytes comparison; a route-attribution assertion (exactly one ofvendor_prebuilt_downloaded/vendor_prebuilt_stub_invalid) that auto-retires the fallback expectation when the depscan fix deploys; purl-scoped applied/failed/idempotency/revert assertions; stale doc comments fixed.attr_mentionline-scanner for the extensions and attr checks; themissclosure widened instead of re-implemented; alias fan-out (authors/author,licenses/license) folded into one helper.Semver note: the new
--vendor-source servicerefusal flips a previously-exit-0 path — but that exit-0 wrote a stub bundler rejects, i.e. an uninstallable project (the D4 defect). The refusal is the bug fix, so this rides a MINOR (also stated in CLI_CONTRACT.md).Noted, deliberately deferred:
.gemarchive is fetched + verified before the stub is validated, so an invalid stub costs one wasted archive download. Left as-is deliberately (reordering the artifact fetches is not worth the churn).Gates re-run on the rebased tip: core lib 2390 passed; repair_vendor_e2e 26, in_process_vendor 37, e2e_vendor_gem_build (host capstone) 2; fmt + clippy
-D warningsclean; live leg (preflight_required_patches_are_published+gem_bundler_vendored_install_proof) green against production, exercising the auto fallback on the real invalid stub with the new route-attribution assertion.🤖 Generated with Claude Code
Note
Medium Risk
Changes gem vendor acquisition and lockfile wiring when service stubs are invalid; behavior is scoped to gem
--vendor-sourcepaths with explicit fallback/refusal, but incorrect heuristic could wrongly reject valid stubs or miss bad ones.Overview
Adds defense-in-depth for gem vendoring when the service’s
gem-stub-gemspecis missing required rubygems fields (summary/authors). Before writing the served stub, the gem vendor backend runs a textual assignment-line check (no Ruby parsing); valid stubs are still written byte-verbatim.--vendor-source auto: loudvendor_prebuilt_stub_invalidwarning naming missing attributes, then local build fallback (same posture as a missing stub).service: hard refuse withvendor_prebuilt_stub_invalidbefore any artifacts are written.Contract & tests:
CLI_CONTRACT.mddocuments the new code in the vendor-source ladder andPatchActiontable. Production e2e upgrades from tolerantgem_bundler_vendored_known_platform_defectto fullgem_bundler_vendored_install_proof(frozen fresh-dirbundle install); passes today via auto fallback while production stubs remain invalid. Hermetic wiremock tests cover service refusal, auto fallback, and the heuristic unit cases.Reviewed by Cursor Bugbot for commit d8ba029. Configure here.