Skip to content

fix(deps): clear the quick-xml DoS advisories and close the relay/ supply-chain blind spot - #433

Merged
runyourempire merged 2 commits into
mainfrom
fix/quick-xml-advisories-and-relay-supply-chain
Aug 15, 2026
Merged

fix(deps): clear the quick-xml DoS advisories and close the relay/ supply-chain blind spot#433
runyourempire merged 2 commits into
mainfrom
fix/quick-xml-advisories-and-relay-supply-chain

Conversation

@runyourempire

@runyourempire runyourempire commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

The premise, verified first

An audit lane claimed the quick-xml suppression in deny.toml:96-112 / .cargo/audit.toml:15-29 had gone stale. It rested on this justification:

"NO consumer in our tree has a released version against >=0.41 yet"

Confirmed false. Read straight out of the registry index (rust_version and deps per published version):

consumer we had latest quick-xml req zip req
calamine 0.25.0 0.36.1 ^0.31^0.41 ^1.0^8.6
docx-rs 0.4.20 0.4.22 ^0.36^0.41 (since 0.4.21) ^0.6.3^8.6
plist 1.9.0 1.10.0 ^0.39.2^0.41

All three shipped support. The ignores were suppressing a live, fixable advisory pair on a parser that reads user-supplied .xlsx / .docx.

What changed

quick-xml (RUSTSEC-2026-0194 / -0195) — resolved, not re-justified. Bumping the three consumers collapses quick-xml 0.31.0 + 0.36.2 + 0.39.4 → a single 0.41.0. Both advisories stop firing on their own, so both ignores are deleted from deny.toml and .cargo/audit.toml (they had diverged; both were checked). RUSTSEC-2023-0071 (rsa) left alone as instructed.

office.rs needed no edit — and that is a verified claim, not an absence of errors:

  • sheet_names() and worksheet_range() have byte-identical signatures in 0.25 and 0.36.
  • Data still has exactly the same nine variants with the same payloads. cell_to_string matches it exhaustively with no wildcard arm, so an added variant could not have compiled.
  • ExcelDateTime's Display impl is byte-identical (write!(f, "{}", self.value)), so DateTime cells format the same.
  • Same for docx-rs: TableChild / TableRowChild are destructured irrefutably, so a new variant there could not have compiled either.

The documented decompression-bomb weakness (the 100 MB cap is on the compressed size) is untouched — separate work, not regressed.

zip — partial. zip 1.1.4 retired as hoped. zip 0.6.6 did not — it is our own direct zip = "0.6", so retiring it is an 8-major API migration across osv/cache.rs, extractors/archive.rs and embeddings_providers/fastembed.rs. No advisory attaches to it, so it is staleness, not exposure. Left as follow-up rather than smuggled into a security PR. Tree is now zip 0.6.6 (ours) + 4.6.1 (tauri-plugin-updater) + 8.6.0 (calamine/docx-rs).

relay/ — 5 vulnerabilities → 0. A TLS-terminating server with no Dependabot entry, no cargo-audit, no CI.

crate change advisory
rustls-webpki 0.103.9 → 0.103.14 RUSTSEC-2026-0049 / -0098 / -0099 / -0104 (cert validation)
spin 0.9.8 → 0.9.9 0.9.8 was yanked
anyhow 1.0.102 → 1.0.104 RUSTSEC-2026-0190
event-listener 5.4.1 → 5.4.2 RUSTSEC-2026-0221
rand 0.8.5 → 0.8.7 RUSTSEC-2026-0097

rsa 0.9.10 remains with no fix available, and is recorded in a new relay/.cargo/audit.toml with evidence that it is not in the build graph: it reaches Cargo.lock only via sqlx's optional mysql backend, which relay never enables — cargo tree -i rsa and cargo tree -i sqlx-mysql both report nothing to print.

Coverage, so it stops recurring. dependabot.yml gains a cargo entry for /relay (not a src-tauri workspace member, so the existing entry never saw it), and nightly-audit.yml's cargo-audit step now loops every Cargo.lock in the repo. Workflow footprint is deliberately limited to those two filesvalidate.yml is being reshaped by peer PRs and is untouched here.

relay/Dockerfile. cargo build --release --locked 2>/dev/null || cargo build --release silently dropped lockfile enforcement and swallowed the reason. Fallback removed. Its base image also had to move 1.82 → 1.95: the lockfile already required 1.88 via time 0.3.47 (jsonwebtokensimple_asn1), so that image could not have built this crate at all — the fallback was hiding a hard failure, not surviving a soft one.

Two things found on the way

1. main was un-committable — independently confirmed, now fixed by #430. scripts/check-file-sizes.cjs exits 1 on src-tauri/src/analysis_rerank.rs (1032 lines against a 1000 hard limit, arrived with #423). The gate scans the whole repo rather than staged paths, so .husky/pre-commit failed for every terminal on every commit — including this one. I hit it, diagnosed it, and fixed it the same way a peer did in #430 (lift the test module into a sibling analysis_rerank_tests.rs via #[path], 1032 → 866). #430 landed first, so that commit has been dropped from this branch by rebase — this PR now contains only the dependency work. Recording it here as an independent second confirmation of both the diagnosis and the chosen fix.

2. cargo clippy --all-targets -- -D warnings does not pass on main (255 pre-existing errors at my branch point, ~all unwrap_used/expect_used in test code). This is not the gate — CI runs cargo clippy ${{ matrix.cargo-features }} -- -D warnings without --all-targets, so the numbers below are from the CI-equivalent invocation. Reported as an observation, not touched.

Verification

check result
src-tauri cargo audit exit 0 — zero vulnerabilities, zero warnings
src-tauri cargo deny check exit 0advisories ok, bans ok, licenses ok, sources ok
relay cargo audit exit 0 (was 5 vulns + 4 warnings + 1 yanked)
relay cargo check --locked --all-targets clean
cargo clippy -- -D warnings (CI-equivalent, default) exit 0
cargo clippy --features experimental -- -D warnings exit 0
cargo fmt --check exit 0
cargo test --lib 4300 passed, 0 failed, 10 ignored

All re-run after rebasing onto c1fd348c (#425, #426, #427, #429, #430, #431 all landed mid-flight).

--features team-sync and --features enterprise fail to compile — pre-existing rot on main (chacha20poly1305::aead::OsRng unresolved, then cascading __cmd__* macro failures), which is what #424 exists to repair. My lockfile diff touches no crypto crate. #424 is still open as of this push, and the CI clippy matrix on main still carries only the default and experimental legs — so the two legs verified above are exactly the gate.

The extractor tests were #[ignore]d and had never run

There are no .xlsx/.docx fixtures anywhere in the repo, so test_real_docx_extraction / test_real_xlsx_extraction were no-ops that returned early. To gain real confidence in an 11-minor-version parser bump I generated real OOXML documents — shared strings, an inline string, numeric and boolean cells, paragraphs and a table — confirmed both #[ignore]d tests pass against them, and separately asserted the extracted text matches the pre-bump formatting contract exactly:

=== Sheet: Budget ===        Hello from 4DA
Item | Cost                  Second paragraph
Widget | 42                  A1 | B1
Gadget | 3.50 | TRUE

That exercises every arm of cell_to_string that a document can reach (shared/inline string, integral float → 42, fractional float → 3.50, bool → TRUE) plus the docx paragraph and table paths. The scratch harness was deleted; no test-file changes ship in this PR.

Deliberately left

  • zip 0.6.6 — direct dep, 8-major API migration, no advisory. Follow-up.
  • office.rs decompression bomb — the 100 MB cap is on the compressed size. Out of scope, not regressed.
  • --all-targets clippy backlog — pre-existing, not the CI gate.
  • validate.yml — peer-owned right now, untouched on purpose.
  • --features team-sync / enterprise — pre-existing rot, fix(build): repair team-sync + enterprise feature rot and gate them in CI #424's job.

runyourempire and others added 2 commits August 15, 2026 01:04
…ain blind spot

The quick-xml suppression in deny.toml / .cargo/audit.toml rested on
"NO consumer in our tree has a released version against >=0.41 yet".
That is no longer true, so the ignore had become a stale lie:

  calamine 0.36.1 -> quick-xml ^0.41   (we were pinned "0.25")
  docx-rs  0.4.22 -> quick-xml ^0.41   (we were locked 0.4.20)
  plist    1.10.0 -> quick_xml ^0.41   (transitive, was 1.9.0)

Bumping all three collapses quick-xml 0.31.0 + 0.36.2 + 0.39.4 into a
single 0.41.0, so RUSTSEC-2026-0194 and RUSTSEC-2026-0195 stop firing
on their own and both ignores are DELETED rather than re-justified.
This matters because that parser sees user-supplied .xlsx/.docx.

RUSTSEC-2023-0071 (rsa) is left alone — its enterprise-gated
justification was re-checked and still holds.

calamine 0.25 -> 0.36 is eleven minor versions, but the API surface
extract_xlsx() uses is unchanged: sheet_names() and worksheet_range()
have identical signatures, and Data still has exactly the same nine
variants with the same payloads (cell_to_string matches it
exhaustively with no wildcard arm, so a new variant could not have
compiled). ExcelDateTime's Display impl is byte-identical across the
two versions, so DateTime cells format the same. office.rs needed no
edit. The 100 MB cap is still on the COMPRESSED size — untouched,
still a decompression-bomb weakness, still separate work.

Falls out of the bump: zip 1.1.4 retires (calamine and docx-rs both
moved to zip ^8.6). zip 0.6.6 stays — it is our own direct
`zip = "0.6"`, so retiring it is an 8-major API migration across
osv/cache.rs, extractors/archive.rs and embeddings_providers/
fastembed.rs. No advisory attaches to it; tracked as follow-up.

relay/ had 5 vulnerabilities, no Dependabot entry and no audit
coverage at all — a TLS-terminating server sitting on four
rustls-webpki certificate-validation flaws:

  rustls-webpki  0.103.9 -> 0.103.14 (RUSTSEC-2026-0049/-0098/
                                      -0099/-0104)
  spin           0.9.8   -> 0.9.9    (0.9.8 was YANKED)
  anyhow         1.0.102 -> 1.0.104  (RUSTSEC-2026-0190 unsound)
  event-listener 5.4.1   -> 5.4.2    (RUSTSEC-2026-0221 unsound)
  rand           0.8.5   -> 0.8.7    (RUSTSEC-2026-0097 unsound)

relay now audits clean apart from rsa 0.9.10, which has no fix and is
NOT in its build graph: it reaches Cargo.lock only via sqlx's optional
mysql backend, which relay never enables (`cargo tree -i rsa` and
`cargo tree -i sqlx-mysql` both report nothing to print). Recorded in
a new relay/.cargo/audit.toml with that evidence.

To stop this recurring: dependabot.yml gains a cargo entry for /relay
(it is not a src-tauri workspace member, so the existing entry never
saw it), and nightly-audit.yml's cargo audit step now loops over every
Cargo.lock in the repo instead of only src-tauri. Workflow footprint
is deliberately limited to those two files — validate.yml is being
reshaped by a peer PR and is not touched here.

relay/Dockerfile: `cargo build --release --locked 2>/dev/null ||
cargo build --release` silently dropped lockfile enforcement whenever
the first form failed, shipping an unaudited resolve with the reason
swallowed by 2>/dev/null. Fallback removed. Its base image also had to
move 1.82 -> 1.95 (matching rust-toolchain.toml): the lockfile already
required 1.88 via time 0.3.47 (jsonwebtoken -> simple_asn1), so that
image could not have built this crate at all — the fallback was
hiding a hard failure, not surviving a soft one.

Verified: src-tauri cargo audit exit 0, cargo deny check exit 0
("advisories ok, bans ok, licenses ok, sources ok"); relay cargo audit
exit 0 and cargo check --locked clean; clippy -D warnings clean for
default and --features experimental; 4289/4290 lib tests pass (the one
failure, locked_db_returns_recovery_failed_without_quarantine, is a
wall-clock assertion that flaked under concurrent load and passes in
0.86s in isolation — unrelated).

The real-parser office tests are #[ignore]d and no fixtures exist in
the repo, so they had never executed. Generated real OOXML .xlsx and
.docx (shared strings, inline strings, numeric/bool cells, paragraphs
and a table), confirmed both #[ignore]d tests pass against them, and
separately asserted the extracted text exactly matches the pre-bump
formatting contract — "Widget | 42", "Gadget | 3.50 | TRUE",
"=== Sheet: Budget ===", "A1 | B1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
…uri audit now fully clean

Last advisory standing in src-tauri. `.husky/pre-push` prints
"Rust dependency vulnerabilities found" on EVERY push because of it,
which is exactly how a real finding gets trained into background noise.

RUSTSEC-2026-0221: event-listener lets `!Send` tags cross thread
boundaries via StackSlot. It is not dead weight — `cargo tree -i
event-listener --target all` puts it on the Linux D-Bus path
(zbus <- notify-rust / tauri-plugin-opener / tauri-plugin-single-
instance), invisible to a plain `cargo tree` on Windows.

Same one-line fix already applied to relay/Cargo.lock in this branch,
where 5.4.2 does get compiled on this machine (flume -> sqlx-sqlite)
and `cargo check --locked --all-targets` passes — so the release is
known-good here even though src-tauri's copy is Linux-only.

src-tauri `cargo audit` now reports zero vulnerabilities and zero
warnings; `cargo check --locked` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
@runyourempire
runyourempire force-pushed the fix/quick-xml-advisories-and-relay-supply-chain branch from f7de0cb to e2a16c4 Compare August 14, 2026 15:23
@runyourempire
runyourempire merged commit 96fbfd9 into main Aug 15, 2026
16 of 18 checks passed
@runyourempire
runyourempire deleted the fix/quick-xml-advisories-and-relay-supply-chain branch August 15, 2026 13:57
runyourempire added a commit that referenced this pull request Aug 15, 2026
main's quick-xml advisory fix bumped calamine 0.25 -> 0.36.1, zip -> 8.6.0,
quick-xml (3 versions) -> 0.41.0, docx-rs 0.4.20 -> 0.4.22 and plist
1.9 -> 1.10, pulling in atoi_simd, debug_unsafe, fast-float2 and
typed-path. 828 -> 832 shipping crates.

The generator caught this on its first CI run: NOTICE was rendered from
the pre-rebase lockfile and `--check` failed against the merge result.
That is the gate doing its job — under the old hand-maintained file this
dependency bump would have silently desynced the attribution users see in
the Third-Party Licenses modal, exactly as the previous four version
errors did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
runyourempire added a commit that referenced this pull request Aug 15, 2026
…n the parsers (#461)

Two defects on the **untrusted-input path** — data fetched from remote
feeds, and files opened from the user's disk.

## Defect 1 (HIGH) — the SSRF guard was pre-flight only

`url_validation::is_internal_url` is a good implementation wired at ten
production sites. But **no client in the codebase configured a
`redirect::Policy`**, so all 21 production `ClientBuilder`s inherited
reqwest's default `Policy::limited(10)`.

A hostile RSS feed (user-addable) or a hijacked curated-feed domain
answering `302 Location: http://127.0.0.1:4446/api/dna` was followed,
the pre-flight check never re-ran, and the internal body was stored as
feed content (`content_enrichment.rs`).

### The fix

`http_client.rs` gains two policies:

| Policy | Behaviour | Applied to |
|---|---|---|
| `ssrf_guarded_redirect_policy()` | refuses **any** hop to an internal
address | 10 clients: `HTTP_CLIENT`, `PROBE_CLIENT`, `TEAM_CLIENT`,
`client_builder_with_proxy`, crates.io, OSV cache + sync, model
registry, translation, settings validation, team-sync scheduler,
webhooks, fastembed download, live reality check |
| `local_aware_redirect_policy()` | refuses an internal hop **unless the
request already started internal** | 11 clients: `LLMClient`, Ollama
(x2), `ollama_capability`, embeddings, local-server probes (x3),
calibration check, dev-frontend gate |

The local-aware variant exists so a user who deliberately points 4DA at
`http://127.0.0.1:11434` keeps working, while a cloud provider still
cannot redirect its way inward. It mirrors the existing `if
self.provider.provider != "ollama"` exemption at `llm.rs:492`.

`Policy::custom` **replaces reqwest's hop limit wholesale**, so the
policy counts hops itself — without that a redirect loop never
terminates. There is a test for exactly that.

### Secondary: the hand-rolled URL parser

`extract_host` split on `/ ? # :` but never on `@`, so
`http://evil.com@127.0.0.1/` produced a host of `evil.com@127.0.0.1`,
failed `parse::<IpAddr>()`, fell through a failing DNS lookup, and
returned "not internal". It also missed the WHATWG-legal obfuscated
literals (`http://2130706433/`, `http://0x7f000001/`, `http://127.1/`,
`http://0177.0.0.1/`).

Host extraction now goes through the `url` crate — the same parser
reqwest uses, so there is no parser differential between validation time
and request time. Credentials-in-URL are rejected outright, matching
what `ipc_guard::validate_url_safe_for_request` already did.

### Correction to the brief

The report suggested `rss.rs:483` / `hackernews.rs:301` would "inherit
the protection" once the redirect policy landed. **They would not.**
Those two fetch feed-supplied URLs guarded only by `starts_with("http")`
— a *direct* SSRF where the first request is not a redirect, so no
redirect policy can see it. Both now pre-flight through
`validate_not_internal`.

## Defect 2 (HIGH) — decompression bombs in default-reachable parsers

**`extractors/office.rs` (default-build reachable).** The 100 MB cap was
on the *compressed* file, with no decompressed bound, entry count, or
ratio check. A 100 MB DOCX of low-entropy XML expands to ~100 GB,
buffered by `read_docx`/calamine before we ever see a parse result.
`guard_ooxml_bomb` now streams every part through `io::sink()` —
decompression CPU, no allocation — and counts the bytes that actually
come out, bounding total size (250 MB), ratio (200:1), and part count
(10,000).

**`extractors/archive.rs` (feature-gated).** All three ZIP guards read
`file.size()` — the *header-declared* size, which the archive's author
controls. `zip` 0.6 builds the DEFLATE reader as
`DeflateDecoder::new(take(compressed_size))` (`read.rs:277`), so the
declared size bounds nothing: declare 1 KB, ship 45 MB. Sizes and ratios
now come from capped reads.

TAR gains the input cap it never had, a `Take` on the decompressed
stream (a `.tar.gz` had no bound at all), and an entry-*scan* counter —
`MAX_FILE_COUNT` only incremented after a successful read, so an archive
of skippable entries looped once per entry on the watcher thread while
holding the DB mutex.

**`osv/cache.rs`.** The ecosystem download called `.bytes()` — unbounded
in-memory buffering of a several-hundred-MB body — and `read_to_string`
per entry. Now streams to disk under a 1 GiB cap and reads each advisory
under 8 MiB.

**Zip-slip is untouched** — `enclosed_name()` and the `ParentDir`
rejection were already correct, and nothing is written to disk.

## Every new test was proven to fail against the pre-fix code

The existing extractor "integration" tests were decorative: `#[ignore]`d
*and* self-neutering, each wrapping its only assertion in `if
test_docx.exists()` against a temp path nothing creates. Those two are
deleted. New fixtures are built programmatically at test time — no
binaries committed.

Each guard was reverted in place and the tests re-run. Observed
failures:

| Test | Failure against pre-fix code |
|---|---|
| `blocks_userinfo_masking_loopback` | `assertion failed:
is_internal_url("http://evil.com@127.0.0.1/")` |
| `blocks_obfuscated_ipv4_literals` | `assertion failed:
is_internal_url("http://2130706433/")` |
| `validate_not_internal_rejects_credentials` | no error raised |
| `shared_http_client_refuses_redirect_to_loopback` | `HTTP_CLIENT
followed the hop: Ok(200)` |
| `probe_and_team_clients_refuse_redirect_to_loopback` | `PROBE_CLIENT
followed the hop` |
| `docx_decompression_bomb_is_refused_on_size` | `got: Failed to parse
DOCX structure` — i.e. *after* inflating |
| `docx_decompression_bomb_is_refused_on_ratio` | same |
| `xlsx_decompression_bomb_is_refused` | `got: Failed to open Excel
workbook` — after inflating |
| `ooxml_entry_count_bomb_is_refused` | same |
| `zip_entry_lying_about_its_size_is_refused` | `bomb payload reached
the output (47185977 bytes of text)` |
| `targz_decompressed_stream_is_bounded` | `the walk ran past the
extraction budget to reach the trailing entry` |
| `tar_input_over_compressed_cap_is_refused` | `got: No extractable text
content found in archive` |

The redirect tests drive a **real loopback HTTP fixture serving a real
302** — `reqwest::redirect::Attempt` cannot be constructed outside its
crate, so a live redirect is the only way to exercise a policy. A
companion test asserts the *stock* reqwest policy **does** follow the
hop, so the fixture cannot silently rot into proving nothing.

The ZIP bomb fixture forges the declared uncompressed size at known byte
offsets in both the local file header (+22) and the central directory
record (+24) — which is exactly what an attacker does by hand.

`legitimate_docx_still_extracts` is the anti-false-positive test: a
genuine DOCX built with `docx-rs` must still parse. Guards that reject
everything are not guards.

## Verification

- `cargo clippy --lib -- -D warnings` — clean, default features
- `cargo clippy --lib --features experimental -- -D warnings` — clean
- `cargo clippy --lib --features archive -- -D warnings` — clean
(archive.rs only compiles under this feature)
- `cargo fmt --check` — clean
- `cargo test --lib` — **4356 passed, 0 failed, 8 ignored**
- `cargo test --lib --features archive` — **4367 passed, 0 failed, 8
ignored**
- Integration tests — **154 passed** (migration 12, pipeline 13,
source_resilience 5, stack_simulation 124)

Rebased onto `8c60321b`, so this is verified against #433's `calamine`
0.36 / `docx-rs` 0.4.22. `office.rs` needed no API changes for that
bump; the rebase applied with no conflicts.

The rebase caught one real flake: the ratio fixture at deflate level 1
landed at ~199:1 against a 200:1 threshold — sitting on its own
boundary. It now builds at level 9 (~1000:1) and asserts the margin up
front, so a future flate2 bump fails with "fixture is no longer a
convincing bomb" rather than a confusing parser error.

## Note

`src-tauri/src/osv/cache.rs` crosses the 700-line soft warning (726).
Warning only, non-blocking; splitting that module is a separate
refactor.

🤖 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 15, 2026
…e NOTICE mechanically (#460)

Two truthfulness defects in public-facing claims, plus the drift that
caused them.

## 1. Rejection-rate claims (`fix(docs)`)

**The headline numbers were real, and I found their source** — commit
`d956d46f`, 2026-04-26, *"ground rejection rate claims in measured
benchmark data"*, which replaced an earlier unmeasured "99%+" claim with
92% / 98% / 77% measured from the 9-persona simulation over 1,997
evaluations. So they were never fabricated.

What was actually broken is that nothing regenerated or enforced them
afterwards:

| Claim | Status |
|---|---|
| "92% rejection, 98% noise accuracy" | True but stale — current
measurement is **93.1% / 98.9%** |
| "215 labeled items" | Stale — corpus is **245** |
| "tested, not asserted" (`README:345`) | **False as written** |
| `cargo test scoring::benchmark` cited for the 9-persona numbers |
**Wrong suite** — it has 2 profiles; the 9 personas live in
`scoring::simulation` |
| de/es/fr/tr locales | Still shipping the **"over 99%"** claim
`d956d46f` was written to remove — those 4 locales were missed |
| 9 locales: "shown in the Evidence tab" | Evidence is in the
**removed-views** list (`ui-slice-navigation.test.ts:46`); repointed to
Signal |

On "tested, not asserted": the suite asserts **floors**, not the
headline. `reality.rs` requires aggregate precision >= 0.70, aggregate
F1 >= 0.40, and >= 80% noise rejection per persona, with per-persona
floors as low as P>=0.10 for bootstrap. Rejection could have fallen from
92% to 81% without failing a single test. Every doc now says so
explicitly.

**Re-measured on the current pipeline** (`cargo test --lib
scoring::simulation -- --nocapture`, 159 passed):

```
245 items x 9 personas -> 1,997 scored evaluations (borderline excluded)
TP 119 · FP 19 · TN 1,646 · FN 213
rejection (TN+FN)/total = 93.1%    noise accuracy TN/(TN+FP) = 98.9%
precision = 86.2%                  blended recall = 35.8%   strong recall = 71.3% (72/101)
```

The dashboard only prints P/R/F1, so rejection rate and noise accuracy
have to be derived from the confusion matrix — that derivation is now
written into the README rather than left as tribal knowledge.

Sanity check under real embeddings (`--features calibrated-sim`, real
fastembed fixtures rather than the synthetic CI baseline): **94.0%
rejection / 98.8% noise accuracy / 83.2% precision**. The headline
claims hold under both embedding modes.

Also fixed `specs/ARCHITECTURE.md`'s unbacked **"99.9% rejection
rate"**.

**No scoring code touched.** The claims were wrong, not the thresholds.

## 2. Third-party attribution (`fix(legal)`)

`NOTICE` was hand-maintained at 92 direct dependencies against **828
shipping Rust crates + 42 production npm packages**, and it is
raw-imported by `ThirdPartyLicensesModal.tsx` — so it is what users
actually see. Confirmed drift:

- `ts-rs` listed as 10 -> **12.0.1**
- `scraper` listed as "0.23 - MIT" -> **0.27.0, and ISC** (licence was
wrong too)
- `chacha20poly1305` 0.10 -> **0.11.0**
- `ammonia` 4.1.4 -> **absent entirely**
- **All 8 shipping MPL-2.0 crates unattributed** (`cssparser` x2,
`cssparser-macros` x2, `selectors` x2, `dtoa-short`, `option-ext`) —
MPL-2.0 §3.2 requires notice
- **Both OFL-1.1 fonts unattributed**, and the OFL text reached no build
output at all. The fonts ship the licence in `node_modules`, but nothing
copied it into `dist/` — OFL-1.1 requires the licence to travel with the
font software.

**Route taken: mechanical generation**, so it cannot drift again.
`scripts/generate-notice.cjs` derives NOTICE from `cargo metadata`
(non-dev closure: normal + build edges, all platforms) and `pnpm
licenses list --prod`. It also:

- emits a reciprocal-licence section with a source-availability
statement (MPL/EPL)
- embeds the full OFL text for each bundled font
- **hard-fails** on strong copyleft, or on an OFL package missing its
licence file
- does not misclassify dual licences — `r-efi` (`MIT OR Apache-2.0 OR
LGPL-2.1-or-later`) neither blocks nor lands in the reciprocal section

Licence *compatibility* was already clean: **no GPL/AGPL/SSPL** anywhere
in the graph.

### Why 29 old entries disappeared

Worth checking during review, since a shrinking NOTICE looks alarming.
All 29 are correct removals:

- **25 dev-only** (`criterion`, `eslint`, `vitest`, `playwright`,
`sharp`, `tailwindcss`, …) — the old file had explicit "Dev
Dependencies" sections. Dev tooling is not distributed, so it needs no
attribution.
- **`ocrs`, `rten`** — behind the non-default `ocr` feature (`default =
["fastembed-local"]`), so they are not linked into the shipped binary.
Release builds pass no `--features` override.
- **`uhlc`, `tokio-tungstenite`** — genuinely gone; neither is in
`Cargo.lock` and `tokio-tungstenite` is not even declared in
`Cargo.toml`. The hand-maintained file was attributing crates that had
already been removed.

### Gate placement

`--check` runs in **`repo-guards`**, the only CI job with no `if:` — so
a Rust-only dependency bump cannot slip past it. That costs the job a
Rust toolchain and a dependency install it did not previously need (~2-3
min); the alternative was a gate that skipped on exactly the PRs that
move dependencies. `--require` makes a missing toolchain a failure
there, so the gate cannot pass by doing nothing. Also wired into `pnpm
run validate` as `validate:notice`.

19 self-tests added to `test:scripts`, per the repo convention that
every guard verifies its own claims.

### The gate caught real drift on its first run

Worth recording, because it is the whole argument for the change. This
branch was cut before #433 landed. CI builds the merge commit, so
`--check` rendered against a lockfile where #433 had bumped `calamine`
0.25 -> 0.36.1, `zip` -> 8.6.0, `quick-xml` (three versions) -> 0.41.0,
`docx-rs` 0.4.20 -> 0.4.22 and `plist` 1.9 -> 1.10, pulling in
`atoi_simd`, `debug_unsafe`, `fast-float2` and `typed-path` — 828 -> 832
shipping crates. It failed, named the exact crates, and the branch was
rebased and regenerated.

Under the hand-maintained file that bump would have silently desynced
the attribution users see in the Third-Party Licenses modal — which is
precisely how the four pre-existing version errors got there.

## Verification

- `cargo test --lib scoring::simulation` — 159 passed
- `pnpm run test` — **1249 passed** (111 files)
- `pnpm run typecheck`, `pnpm run build` — clean
- `pnpm run test:scripts` — **88 passed** (69 + 19 new)
- `check-file-sizes`, `check-doc-location`, `check-vanity-metrics`,
`check-llm-gate-honesty`, `validate-translations` — clean (14
translation warnings, all pre-existing and unchanged)
- `generate-notice.cjs --check` — idempotent

## Follow-ups (not done here, deliberately)

1. **`--features calibrated-sim` currently fails 9 tests** (4 persona
reality tests, 2 enrichment, golden snapshot, registry precision,
version comparison). Pre-existing — this PR touches no Rust. The feature
is off by default and not in CI, but its thresholds have drifted from
its fixtures.
2. The simulation prints P/R/F1 but not rejection rate or noise
accuracy. Emitting them directly would remove the hand-derivation the
README now documents — needs a change in `scoring/`, which was out of
bounds for this PR.
3. No guard ties the documented CI floors to the actual `assert_quality`
values in `reality.rs`. If someone lowers a threshold, the docs go stale
silently.
4. **Bundled ML model weights are unattributed.** `tauri.conf.json`
bundles `src-tauri/models` (`text-detection.rten` 2.4 MB,
`text-recognition.rten` 9.3 MB, fetched by `fetch-ocr-models.cjs`,
untracked in git). These are third-party artifacts that ship in the
installer, and this generator covers source dependencies only.
Separately worth a look: the models ship even though the `ocr` feature
that reads them is off by default.

🤖 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>
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