Skip to content

fix(utf8): eliminate 23 string byte-slicing panic vectors on ingested text - #422

Merged
runyourempire merged 1 commit into
mainfrom
fix/utf8-boundary-panics
Aug 13, 2026
Merged

fix(utf8): eliminate 23 string byte-slicing panic vectors on ingested text#422
runyourempire merged 1 commit into
mainfrom
fix/utf8-boundary-panics

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

What

&s[a..b] on a &str panics when a bound lands mid-UTF-8-sequence. 4DA ingests arbitrary internet text (HN, Reddit, RSS, Mastodon, arXiv, OSV/GHSA, YouTube), so every byte index derived from arithmetic was a live crash waiting on the right title.

This audits every char-boundary-panicking API — not just indexing — and fixes all 23 unguarded sites.

Audit coverage

API Hits Verdict
&s[a..b] 418 21 unguarded
str::split_at 1 safe (&[u8], len-guarded)
String::truncate 57 all receivers are Vec, zero String
String::replace_range 4 safe (find-derived, ASCII delimiters)
String::split_off / drain 6 safe (all Vec)
insert / remove / get_unchecked / .get(..).unwrap() 0 clean
multi-line slice forms (rg -U) 0 clean
is_char_boundary walk-downs 8 all underflow-guarded
case-fold index desync 4 all 4 are bugs

Highest reachability first

  • source_fetching/fetcher.rs (x2), extractors/pdf.rs, toolkit_http.rs — fixed byte caps (500 KB / 5 MB) cut mid-char on any large non-ASCII payload. Runs on every fetch, on user PDFs, and on any URL the user probes.
  • preemption.rs — the advisory-subject window fell back to a raw 80-byte cut; extract_advisory_id had two stacked bugs (desync + start + 30).
  • monitoring_briefing.rs — the 19-byte GHSA window and 4-byte CVE year window were length-guarded but not boundary-guarded.

The index-desync class

to_lowercase() / to_uppercase() are Unicode-aware and change byte length (U+FB01 -> FI shrinks; U+0130 lowercases to two code points and grows). Four sites searched a case-folded copy and sliced the original with the result — a panic when the shift lands mid-char, and silently byte-shifted output when it does not. In preemption::extract_advisory_id that corrupted the cross-tier dedup key, so alerts stopped collapsing.

All four now use the ASCII-only folding variants, which preserve byte length; every needle involved is pure ASCII. prompt_safety.rs already did this correctly and was the model.

Design notes

Fixes are per-class, matching each call site's intent rather than routing everything through one helper:

  • floor_char_boundary / ceil_char_boundary for byte caps (also subsumes the old .min(len) clamps)
  • chars().take/skip for the three key-mask sites — their doc comments all say "chars", and the old byte-length star count rendered one * per byte
  • str::get for fallible fixed-width windows
  • byte-wise ASCII checks where only ASCII can match

Deliberately does not touch utils/text.rs. #416 owns that file and adds truncate_display, which is a display truncator (word-break + ellipsis) — semantically wrong for a memory cap, where an ellipsis would corrupt the payload. Zero file overlap with #416, #418, #419, #420, #421.

Verification

18 regression tests added, and each was verified to fail on the pre-fix code: 11 of the 12 original expressions panic on the exact inputs the new tests use; the 12th returns an empty advisory id.

  • cargo clippy -- -D warnings (default + --features experimental): clean
  • cargo fmt --check: clean
  • cargo test --lib: 4437 passed, 0 failed (was 4419)

Pre-existing, not addressed here

cargo clippy --all-features fails on main with 56 errors in team_sync_commands / webhooks / team_sync_crypto — including dependency-API drift (chacha20poly1305::generate_nonce no longer exists, deprecated sha2 from_slice). CI builds only default and experimental, so this is not gating, but it is worth a separate issue.

… text

`&s[a..b]` on a `&str` panics when a bound lands mid-UTF-8-sequence. 4DA
ingests arbitrary internet text (HN, Reddit, RSS, Mastodon, arXiv, OSV/GHSA,
YouTube), so every byte index derived from arithmetic was a live crash. An
audit of ALL char-boundary-panicking APIs — not just indexing, but also
split_at, String::truncate, replace_range, split_off, drain, insert/remove,
get_unchecked and multi-line slice forms — found 21 unguarded sites plus a
distinct index-desynchronization class.

Highest reachability first:

- source_fetching/fetcher.rs (x2), extractors/pdf.rs, toolkit_http.rs:
  fixed byte caps (500 KB / 5 MB) cut mid-char on any large non-ASCII
  payload. These run on every fetch and on the user's own PDFs.
- preemption.rs: the advisory-subject window fell back to a raw 80-byte
  cut; extract_advisory_id took an index from a `to_uppercase()` copy and
  applied it to the original, then capped an unterminated id at start+30.
- monitoring_briefing.rs: the 19-byte GHSA window and the 4-byte CVE year
  window were length-guarded but not boundary-guarded.

The index-desync class deserves calling out: `to_lowercase()`/`to_uppercase()`
are Unicode-aware and CHANGE BYTE LENGTH (U+FB01 -> "FI" shrinks; U+0130
lowercases to two code points and grows). Four sites searched a case-folded
copy and sliced the original with the result — a panic when the shift lands
mid-char, and silently byte-shifted output when it does not. In
preemption::extract_advisory_id that corrupted the cross-tier dedup key.
All four now fold with the ASCII-only variants, which preserve byte length;
every needle involved is pure ASCII. prompt_safety.rs already did this right.

Fixes are per-class, matching each call site's actual intent rather than
routing everything through one helper: floor/ceil_char_boundary for byte
caps, chars().take/skip for the three key-mask sites (whose doc comments all
say "chars"), str::get for fallible fixed-width windows, and byte-wise ASCII
checks where only ASCII can match. Deliberately does NOT touch utils/text.rs
— PR #416 owns that file and adds truncate_display, which is a *display*
truncator (word-break + ellipsis) and would be wrong on a memory cap.

18 regression tests added. Each was verified to FAIL on the pre-fix code:
11 of the 12 original expressions panic on the exact inputs the new tests
use, and the 12th returns an empty advisory id.

cargo clippy -D warnings (default + experimental): clean
cargo fmt --check: clean
cargo test --lib: 4437 passed, 0 failed (was 4419)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
@runyourempire
runyourempire merged commit 330df19 into main Aug 13, 2026
10 checks passed
@runyourempire
runyourempire deleted the fix/utf8-boundary-panics branch August 13, 2026 16:42
runyourempire added a commit that referenced this pull request Aug 14, 2026
…uracy, close enforcement gaps (#421)

Remediation of the full-codebase audit (2026-08-12). **390 files changed, +3,349 / −54,041.**

Every deletion was grep-verified repo-wide before removal, and every surface was re-verified after. Where a claim from the audit turned out to be wrong, it is corrected below rather than quietly dropped.

## Verification

| Gate | Result |
|---|---|
| `cargo test --lib` | **4,267 passed, 0 failed** |
| `cargo build --lib` | zero warnings |
| `cargo fmt --check` | clean |
| `pnpm typecheck` | 0 errors |
| `pnpm test` | 1,249 passed |
| `pnpm build` | ✓ |
| site build | ✓ (corrections confirmed in built HTML) |
| mcp-4da-server build + tests | ✓ 122 passing |
| **IPC health** | **100%** — 0 ghost, 0 unregistered (was 95.4%) |
| `validate-translations` | 0 errors |
| `check-remove-by` | 0 expired (was ~40) |
| `check-release-channel` | passes |

## What changed

**Dead code.** The V1 scoring pipeline had been unreachable since 2026-03-03 (`const USE_V2: bool = true`, no override) yet was still co-maintained — `pipeline.rs` was edited in the AD-029 PR the day before this audit "to keep both pipelines aligned". ~3,100 lines removed. Plus `job_queue.rs` (564) and `reachability.rs` (195), both entirely dead; 18 ghost Tauri commands and their newly-orphaned backing modules (~5,800 lines, incl. `project_health.rs` and 8 `content_personalization` submodules); 1,138 dead lines from the published `@4da/mcp-server`; 14 dead React components; 23 orphan scripts; 9,230 dead i18n keys across 13 locales.

**Correctness fixes found along the way.**
- 19 curated feeds were 404ing on every cycle — repaired via RSS autodiscovery rather than dropped (SQLite moved to its GitHub releases feed, which actually matches its `release_notes` type). 124 of 125 now verified serving live items.
- Dependency-snapshot expiry existed but was never called, so `dependency_snapshots` grew forever. Wired into `run_maintenance` — via a connection-taking helper, because the obvious call would have deadlocked on a mutex `run_maintenance` already holds.
- Bluesky's 7 configured queries all fetched the same hardcoded URL (6 wasted requests/cycle, dead ACE shaping). The GitHub fallback parsed an HTML page as RSS and could never yield items. The audio extractor advertised 6 formats it cannot transcribe.
- The site emitted **no robots.txt at all** — wrong passthrough path, and `.txt` isn't an Eleventy template format.
- `site/rotate-key.sh` rotated the live Stripe key against the **dead Vercel project**; a rotation would have silently failed to change the key the live deployment uses.
- Stale `_site` output was reaching production (Eleventy never cleaned it, `cf:deploy` uploaded wholesale), which is why 4da.ai served old server-side source and the internal Stripe E2E harness.

**Legal / doc accuracy.** The privacy policy named Vercel as data processor in six places — including the GDPR international-transfer clause — three weeks after the Cloudflare migration. Licence validation is cached 90 days, not 24 hours. Terms were missing the $299 Lifetime plan, used USD where the operative terms use AUD, and described a Keygen machine fingerprint the code does not send. `LICENSE-ACTIVATION` documented a device-limit and deactivation flow that does not exist. Tier lists corrected: Developer DNA, NL search and AI briefings are free per AD-025/AD-026, and Score Autopsy, signal chains and channels have no gate at all.

**Enforcement.** Added `check-remove-by.cjs` — the repo carried its own "REMOVE BY" cleanup contract that nothing ever checked, so ~40 markers had silently expired. Also replaced the pre-commit dead-code gate's shell implementation, which forked a `grep` **per line** of every staged annotated file (20+ minutes once a 5k-line file was staged; the hook's own comment recorded the symptom). Same work now runs in **1.17s**, negative-tested to confirm it still blocks.

## Corrections to the audit's own claims

- **"9.8 MB dead STREETS payload"** — not simply dead. `scoring/context.rs` → `assemble_profile` → `assemble_playbook_progress` reads `docs/streets/` at runtime. It is *effectively* inert (the Playbook UI is gone, so `playbook_progress` can never be populated and it always computes zero), but removing the bundle is a scoring-input change and was left alone.
- **"~4,800 orphaned lines behind ghost commands"** — overstated. `sovereign_developer_profile`, `content_personalization::context`, `tech_radar`, `playbook_commands` and `suns` all have live non-command callers. Only the command entry points were removable; the load-bearing helpers stayed.
- **1.1.0 version bump** — recommended on semver grounds, then **reverted**: `check-release-channel` encodes `EXPECTED_DESKTOP_VERSION_LINE = '1.0'` with "do not publish 1.1.0 yet". The gate reflects a deliberate decision and wins.
- **i18n purge bug (mine).** The first pass treated plural storage forms (`key_one`/`key_other`) as independent keys; they are referenced by their base key via `t('key', {count})`, so 24 live strings were deleted. Caught by checking every literal `t()` call site against the pre-purge baseline, restored, and redone plural-aware — **0 of 1,800 call sites broken**. That check also surfaced **185 keys already unresolvable before this work**, a pre-existing defect worth its own fix.

## Not done — needs a decision or its own PR

1. **The forked source-fetch pipeline** — highest-value remaining fix. The routine path lacks freshness/fallback/per-feed-error recording; the deep-scan path lacks the quality gate (`apply_source_quality_gate` has exactly one call site repo-wide). `sources/fallback.rs` is therefore effectively dead in normal operation.
2. **Two migration systems on one database** — `ace/db.rs` runs a second 469-line `migrate()` with no version tracking and error-swallowing ALTERs; whichever init runs first wins. Already caused the documented `kv_store` affinity drift.
3. **`ipc_guard.rs`** — tested SSRF/path-traversal validators with zero production callers. Wire them in or delete them.
4. **Hermetic Fresh-Clone never runs on `main`** — its path filters skip the matrix, so the workflow's green ticks on `main` are *skipped* jobs, not passes. Combined with no Visual Studio on the runner image, a transient `prebuild-install` failure (seen on this PR: `socket hang up`) falls through to `node-gyp` and hard-fails with nothing to fall back on. Wants a retry around the frozen install.

## Done since this description was first written

- **`site/src/signal.njk`** rewritten — 9 cards to 6 (Blind Spots, Knowledge Gaps, Cross-Project Intelligence, Semantic Shifts, Standing Queries, Precision Ledger). Score Autopsy and signal chains are now stated as free in the FAQ rather than sold as Signal-tier.
- **Site deployed** — `wrangler pages deploy --branch main`; the legal corrections are live on 4da.ai and verified against the domain, not the CLI's success line.
- **Merged `origin/main`** (#422, UTF-8 panic-vector fix). Two conflicts, both resolved toward this branch's deletions after verifying the code was unreachable: `template_processor.rs` (module deleted; no callers) and `community_intelligence.rs` (peer's fix sat inside `get_community_status`, a ghost command removed here). Re-verified after merging: `cargo test --lib` 4,267 passed, `pnpm test` 1,249 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01WJ6BP3GX5HYrnjW1nGtrvC
runyourempire added a commit that referenced this pull request Aug 14, 2026
…g background refresh (#428)

## The bug

A panic anywhere in the scheduled-analysis path **permanently disabled
background refresh for the rest of the process lifetime**. No error, no
UI signal — the feed just quietly stopped updating.

Verified mechanism:

1. `monitoring.rs:1183` / `:1335` claims the gate:
`is_checking.swap(true, SeqCst)` — **before** `scheduled-analysis` is
emitted.
2. `app_setup.rs:785` ran `run_scheduled_analysis(handle)` inside a bare
`tauri::async_runtime::spawn`. The `JoinHandle` is dropped, so nothing
ever observes a `JoinError`.
3. That awaits `fill_cache_background(&handle).await` unguarded
(`app_setup.rs:1985`).
4. **Every** site that clears the gate — `app_setup.rs:2011`, `:2109`,
`monitoring.rs:1348`, `monitoring_notifications.rs:69` — sits **after**
the work.

So one unwind skipped all four clear sites and latched `is_checking` at
`true` forever. Every subsequent tick saw `is_checking == true` and
silently skipped itself.

This was already a known shape with no recovery path:
`void_engine/heartbeat.rs:158` carries the comment *"check if monitoring
is_checking stuck (simple heuristic)"*. `headless.rs:258` had the twin
failure — an unwind out of a cycle propagated through the daemon loop
and killed `fourda-engine` outright.

## The fix

Contain the panic at the boundary so a bad cycle becomes a **skipped
cycle**, not a permanent wedge.

- **`src-tauri/src/task_guard.rs`** (new) — `contain()`, an
`AssertUnwindSafe(..).catch_unwind()` wrapper that logs the panic
payload and returns `None`. Mirrors the pattern already proven in
`analysis_status.rs:59-68`.
- **`app_setup.rs:1983-2025`** — `run_scheduled_cycle_contained()` wraps
the cycle and releases the gate in the **recovery arm only**.

Deliberately *not* an unconditional clear: clearing after a normal
completion could race a tick that has already claimed the gate for the
*next* cycle, trading a wedge for two concurrent scheduled analyses
against shared state. The normal paths already release it; only the
unwind path was unreachable.
- **`headless.rs:583-599`** — the daemon loop survives a panicking cycle
and retries on the next tick instead of dying.
- **`headless.rs:133-144`** — `--once` exits with its documented code
`1` instead of an unwind/abort, so an OS scheduler records a clean
failed run.

## Proving the regression test is real

A test that passes both before and after proves nothing, so this was
falsified explicitly: the containment in `run_scheduled_cycle_contained`
was temporarily replaced with a bare `cycle.await` (the exact unfixed
shape) and the suite re-run.

```
cycle_panicking_before_first_await_releases_the_gate ......... FAILED
panicking_scheduled_cycle_releases_the_in_flight_gate ........ FAILED
normal_completion_leaves_the_gate_exactly_as_the_cycle_left_it  ok
normal_completion_preserves_the_cycles_own_gate_release ...... ok
```

Both panic tests fail against unfixed code (the unwind escapes and kills
the test); both success-path tests still pass, confirming they are not
just passing by accident. The containment was then restored and all four
pass.

The tests use a local `MonitoringState` rather than the process-wide
global, so they are hermetic under parallel execution.

## `clippy::string_slice` — measured, then scoped

The intent was a crate-wide `string_slice = "deny"` on the theory that
residual hits would be few after #422. **Measured against this tree it
fires 280 times**, so that premise did not hold:

| Area | Hits | Area | Hits |
|---|---|---|---|
| `sources/` | 55 | `monitoring_briefing.rs` | 13 |
| `ace/` | 38 | `preemption.rs` | 10 |
| `scoring/` | 36 | `diagnostics.rs` | 7 |
| `content_personalization/` | 25 | *(~90 others)* | — |

The lint **is** the right one: the only two hits in `source_fetching/`
are #422's own `floor_char_boundary` slices, which confirms it would
have flagged all 23 original panic sites. But bulk-`#[allow]`ing 278
pre-existing sites — 55 of them in `sources/` adapters that parse
ingested text — would risk **cementing a live panic** behind an allow
that claims safety without an audit. That is the same "large churn" bar
this PR was told to apply to `indexing_slicing`.

So it is adopted **per-module**, starting where the panic class actually
lives:

- `#![deny(clippy::string_slice)]` on `src/source_fetching/mod.rs` — the
module that caps scraped bodies, and where two of #422's 23 panics were.
- The two flagged sites collapse into one `cap_on_char_boundary()`
helper carrying the single char-boundary proof, removing the
duplication.
- `fetcher_tests.rs` previously **re-implemented** the capping logic
inline (`content[..CONTENT_CAP]`), so it asserted against a copy of the
logic and could never have caught the original bug. It now calls the
real function, plus new multi-byte (3-byte `あ` across every cap 1..=8)
and emoji (4-byte) boundary cases.
- `Cargo.toml` carries the measurement and the promotion path for
whoever audits the backlog.

## Verification

| Check | Result |
|---|---|
| `cargo clippy -- -D warnings` (default) | clean |
| `cargo clippy --features experimental -- -D warnings` | clean |
| `cargo fmt --check` | clean |
| `cargo test --lib` | **4446 passed**, 1 pre-existing flake |

The one failure is
`db::migrations::recovery_tests::locked_db_returns_recovery_failed_without_quarantine`,
a timing assertion (*"locked DB recovery must return quickly, elapsed
32.7s"*). It **passes in 2.03s in isolation** — starved by parallel load
on a busy machine, unrelated to this diff, which touches no DB-migration
code.

`cargo clippy --all-features` is pre-existing broken on main (~56 errors
in `team_sync`/`webhooks`, `chacha20poly1305::generate_nonce` gone from
the locked `aead 0.6.1`) and was not touched. This diff cannot affect
it: `source_fetching` contains **zero** `cfg(feature)` code, so the
scoped deny behaves identically under every feature combination.

## Follow-ups deliberately left out

- **`clippy::indexing_slicing`** — not added, as instructed. Not
separately measured; expected to exceed the 280 `string_slice` count
since it covers slices and index ops on all sequence types.
- **The 278-site `string_slice` backlog** — needs a real per-site audit,
best done module-by-module (`sources/` 55 first — highest ingested-text
exposure). Some of those may be live panics of exactly the #422 kind.
- **The other ~54 unguarded `spawn` sites** — scoped out per
instructions. `contain()` is now available for them. No `spawn_guarded`
wrapper was added: both call sites here need custom recovery (a gate
release / an exit code), so a generic spawner would have had zero users
and tripped the dead-code doctrine.
- **`crash_guard` interaction (pre-existing, worth knowing)** —
`crash_guard.rs` installs a panic *hook* that zeroizes API keys on
**any** unwind, including ones caught by `catch_unwind`. That already
applied to the existing guards in `analysis_status.rs` and
`extractors/pdf.rs`; this PR does not change it. But it means a
*survived* scheduled panic leaves the process running with zeroized LLM
keys. std's hook API cannot distinguish caught from fatal panics, so
fixing it is a separate design question.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
runyourempire added a commit that referenced this pull request Aug 16, 2026
…on the user's own tech stack

#422 fixed 17 byte-slicing sites. It could not fix seven it did not know
about: the same word-boundary helper had been written **eight times**
across the tree, and exactly one copy — `scoring/utils.rs` — was UTF-8
safe. The other seven advanced their search cursor with

    search_from = abs + 1;      // next iteration: text[search_from..]

`abs` is the START of a match, so `abs + 1` is a char boundary only when
the needle's first char is one byte. And the advance is reached ONLY when
the word-boundary test FAILS — i.e. when the match abuts an alphanumeric
char. Both conditions must hold, which is exactly why every ASCII test in
the tree passed over this for as long as it existed. ("cafe2" is safe;
"eclair2" is not, because the term's first char is two bytes.)

**The P0 is `signals.rs`.** Verified chain: `scoring/pipeline_v2.rs` ->
`clf.classify(..., &ctx.declared_tech, ...)` -> `has_word_boundary(&title_lower, &t)`.
`ctx.declared_tech` is the tech stack the user typed at onboarding, and
this runs on every item of every scoring pass. One non-ASCII stack entry
plus a title where that term abuts an alphanumeric char aborted the whole
pipeline. `signals.rs` also had no empty-term guard (N3/N5/N6 did), so an
empty stack entry walked the string one byte at a time and panicked at the
first ASCII-letter-followed-by-multibyte position.

Fixed sites: signals.rs, scoring/dependencies.rs, package_ambiguity.rs,
knowledge_decay.rs, dep_linker.rs, preemption.rs, competing_tech.rs,
stacks/scoring.rs. The last two take their terms from const tables today,
so they were latent — one non-ASCII entry away from live.

**The duplication is the actual defect**, so it is gone. The correct
implementation is promoted to `utils/text.rs` — not `scoring/utils.rs`,
because six of the eight call sites live outside `scoring/` and should not
take a dependency on scoring internals for a plain string primitive, and
because `utils/text.rs` already houses `truncate_utf8` whose doc comment
states the identical rationale. `scoring::utils` now re-exports it, so the
~30 `super::utils::has_word_boundary_match` call sites are untouched.

The predicates genuinely disagree and collapsing them would have been a
behaviour change, not a refactor, so the shared API is parameterised:

  - `has_word_boundary_match` — alphanumeric boundaries (5 sites)
  - `has_word_boundary_match_with_ext` — plus `.js`/`.ts`/`.rs` as a right
    boundary, for the package-name matchers ("next.js" IS `next`)
  - `has_bounded_match(.., is_word_char)` — dep_linker and stacks/scoring
    treat `-`/`_`/`.`/`@` as name-internal
  - `match_offsets` + `char_before`/`char_at` — for preemption's asymmetric
    rule (left must be non-alphanumeric, right need only not be a hyphen)
    and dependencies' `.js`-suffix logic

Boundary tests now run on CHARS, not bytes, everywhere. `as_bytes()[i-1]
.is_ascii_alphanumeric()` is false for every UTF-8 continuation byte, so a
non-ASCII letter glued to the term ("иgo") read as a word boundary and
"go" matched — bug E, previously fixed in one copy only.

Also fixes a false documented invariant in the same family
(`scoring/dependencies.rs`). `has_adjacent_version_literal` took one
`name_len` for every position, commented "every accepted form equals the
normalized single-token name, so `name_len` is uniform". It is not:
`normalize_package_name` strips a leading `@`, so `@foo` yields forms of
length 3 and 4. Every `@foo` hit started its version scan one byte INSIDE
the name — and did so with unsnapped arithmetic, which panics when that
lands mid-char. `package_name_positions` now returns `(offset, form_len)`
pairs, so the arithmetic is correct by construction rather than by a
premise the next edit would have trusted.

Every fix has a test that fails without it — a non-ASCII term (multi-byte
FIRST char) abutting an alphanumeric char, asserting both no-panic and the
correct boolean — plus one end-to-end test per live entry point
(`SignalClassifier::classify` with a non-ASCII declared stack,
`compute_competing_penalty` with a non-ASCII primary stack).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
runyourempire added a commit that referenced this pull request Aug 17, 2026
…on the user's own tech stack (#471)

Closes the UTF-8 panic vectors that #422's sweep did not know about,
plus the INV-003 violation and the unvalidated numeric IPC boundary.

`cargo fmt --check` clean · `cargo clippy -- -D warnings` (the repo's
gate) clean · `cargo test --lib` **4,415 passed, 0 failed** · file-size
gate clean.

## Eight copies of one helper, seven of them broken

`has_word_boundary_match` exists in eight places. Exactly one was
correct. The other seven advanced the search cursor with `search_from =
abs + 1`, where `abs` is the **start** of a match — which is a character
boundary only when the needle's first character is one byte.

The worst copy, `signals.rs`, takes **the user's own onboarding tech
stack** as its needle and runs on every item in every scoring pass,
reached from `pipeline_v2.rs` → `clf.classify(..., &ctx.declared_tech,
...)`. It also had no empty-term guard, which three of its siblings did
have, so an empty stack entry walked the string byte by byte.

### The brief I was working from was wrong about the trigger, and it
mattered

I was told a non-ASCII term abutting an alphanumeric character panics.
That is too loose: the term's **first character** must be multi-byte.
`"café"` is safe — `c` is one byte, so `abs + 1` lands cleanly.
`"éclair"` is not.

My first pass of tests used `café`. Every one of them passed against the
unfixed code. I caught it before committing and rewrote all of them with
`éclair` / `привет` / `我们` / `🦀`. Flagging it because a suite of tests
that cannot fail is precisely the defect this audit was about, and I
nearly shipped one into the fix for it.

### Two of the seven are not copies

`preemption::is_compound_prefix_match` and
`scoring::dependencies::package_name_positions` share the defective
cursor but implement genuinely different boundary rules — the first is
asymmetric (left must be non-alphanumeric, right need only not be a
hyphen), the second handles `.js`/`.ts`/`.rs` suffixes and sentence
periods. They cannot collapse into one boolean helper without changing
behaviour, so they take the new `match_offsets` / `char_before` /
`char_at` primitives instead, with the divergence documented in place.

### Where the shared helper went

`utils/text.rs`, not `scoring/utils.rs`. Six of the eight call sites
live outside `scoring/`, and making `preemption`, `dep_linker`,
`competing_tech` and `stacks` depend on scoring internals for a plain
string primitive is the wrong direction. `utils/text.rs` already houses
`truncate_utf8`, whose doc comment states the same rationale.
`scoring::utils` re-exports it, so roughly 30 existing call sites are
untouched.

## Four more defects in the same family

**A truncated model response panicked the judge.** `llm_judge.rs` did
`&response[start..=end]` from `find('[')` and `rfind(']')` with no
ordering guard, so a response whose last `]` precedes its first `[`
panics. Its sibling in `blind_spots.rs` already had the guard and a doc
comment promising "never a panic". The test that should have caught it
**re-implemented the unguarded expression inline** instead of calling
the function, so the suite reproduced the bug rather than detecting it.
Both fixed.

**Streaming destroyed every multi-byte character straddling a network
chunk.** All three transports (Anthropic SSE, OpenAI SSE, Ollama NDJSON)
called `String::from_utf8_lossy` per chunk, so a character split across
a packet boundary became U+FFFD in both halves — silent corruption of
user-visible output, worst on non-English text and emoji. Now decoded at
line boundaries.

**A false invariant in the version-literal check.**
`has_adjacent_version_literal` computed an unsnapped offset while
trusting a comment claiming all accepted forms share one length. They do
not: `normalize_package_name` strips a leading `@`, so `@foo` yields
forms of length 3 and 4.

**An unvalidated number from the frontend could abort the process.**
`taste_test_respond` passed `item_slot: usize` straight from IPC into
`assert!(item_slot < NUM_ITEMS)` — `assert!`, not `debug_assert!`, so
live in release. `ipc_guard.rs` validated strings, URLs and paths but
had no numeric validator at all. It does now. Because the command is
`async`, its panic also left the frontend's `invoke()` promise unsettled
until the 30-second timeout — so this was a process abort *and* a UI
hang. The assert is downgraded to `debug_assert!` with a release
backstop that returns rather than indexing out of bounds.

## INV-003: the file watcher could die and nothing could tell

`cb(changes)` ran unguarded inside the watcher thread, so a panic
anywhere in the extractor chain killed it. What made it silent is worse
than the panic: the `running` flag it would have set is **private with
no getter and read by nothing**, and the only health surface counted
`file_signals` rows in the last hour — so a dead thread reported Healthy
for an hour and then looked exactly like an idle developer.

The callback is now contained, `running` is cleared on panic and logged
at error level, and `check_watcher` is driven by **liveness** rather
than row counts. `check_all_components` takes `watcher_alive` as a
parameter rather than fetching it internally, because the one production
caller already holds the ACE read guard and the connection lock —
fetching inside risked re-entrancy.

## Verification

I reverted **every** fix in one pass, keeping all the new tests, and ran
the suite: **20 tests went red.**

One did not, and that is the useful part.
`compute_competing_penalty_survives_non_ascii_stack` passed in both
states, because `compute_competing_penalty` only reaches the helper when
the stack entry is a **key of the `COMPETING_TECH` const table** — a
non-ASCII entry can never get there, so my end-to-end test was vacuous.
It is replaced (`975ecdf6`) with a test of the actual premise: every key
and competitor in that table is ASCII, which fails the day that stops
being true.

## Lint adoption

`#![deny(clippy::string_slice)]` now covers **14 modules**, up from one.
Eleven remaining slices inside them carry `#[allow]` with a stated
boundary proof. The tree-wide count went 246 → 221 after the fixes →
**210** after annotations. The `Cargo.toml` comment quoted a stale 280;
it is corrected and now says to re-measure before quoting. No crate-wide
deny — the remaining backlog (`ace/scanner.rs` 30,
`monitoring_briefing.rs` 13, `sources/*` ~45) is real, and
blanket-allowing it would cement whatever live panic is hiding in it.

## Two caveats

`cargo clippy --all-targets -- -D warnings` is **not** clean on this
tree — 1,004 findings, pre-existing and toolchain-driven, with
`tests/victauri_dogfood.rs` alone accounting for 579. I verified this
branch adds none by intersecting the finding list against its own
changed files: the six overlapping files carry only pre-existing
findings. The two this branch did introduce were removed before commit.

`utils/text.rs` (703) and `llm_stream.rs` (710) crossed the 700-line
**warn** threshold. No errors and no gate breakage, but they belong on
the split-candidates list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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