Skip to content

fix(osv): drop the expired REMOVE BY field; unblock Rust commits on the ghost gate - #434

Merged
runyourempire merged 1 commit into
mainfrom
fix/osv-remove-by-and-ghost-backlog
Aug 14, 2026
Merged

fix(osv): drop the expired REMOVE BY field; unblock Rust commits on the ghost gate#434
runyourempire merged 1 commit into
mainfrom
fix/osv-remove-by-and-ghost-backlog

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

Two pre-existing blockers, both surfaced by trying to wire a gate that had never been wired. Plus one finding that needs a product decision, not a code fix.

1. check-remove-by.cjs was connected to nothing

It shipped in #421 — mine — to enforce the repo's own "REMOVE BY" contract. It is absent from package.json, .husky/, and every workflow. It has been inert since the day it was written.

Running it by hand found one deadline that fell due that same day: osv::Affected::versions, REMOVE BY 2026-08-15.

Removed the field, after verifying it is genuinely inert:

  • no .versions read anywhere in src-tauri/src/osv/
  • matching runs off affected_ranges via check_version_affected, which falls back to "assume affected" when it cannot decide — so an advisory carrying only versions still alerts, and dropping the field costs no coverage
  • serde ignores unknown fields, so the wire format is unchanged

Five constructors set it to None (one production merge path in sync.rs, four fixtures); all removed.

2. The ghost gate blocked every Rust-touching commit

#425 broke open a closed gate loop and seeded a 119-entry backlog on 2026-08-14, but that seed predates the post-#421 tree — so 13 commands were left unallowlisted and the gate failed on main for anyone staging Rust.

These are not a #421 regression, and the backlog entries say so. Each was orphaned when its only caller was deleted — but that caller was a component nothing mounted. Verified at 8a170c4b^: ConvergenceTab.tsx was referenced solely by itself and its own test. The features were already unreachable; #421 removed dead code and the then-broken detector hid the consequence.

Added to scripts/ghost-command-backlog.json so the gate blocks NEW regressions again.

3. ⚠️ Needs a decision: Signal-tier features with no UI

Allowlisting is triage, not the fix. 13 of the dead commands are Signal-tier, and two cards currently on /signal have no user interface at all:

Marketed on /signal Backing commands UI?
Standing Queries create_standing_query, list_standing_queries, delete_standing_query, get_standing_query_suggestions none
Cross-Project Intelligence get_cross_project_dependencies, get_tech_convergence, get_project_health_comparison none
Blind Spots / Knowledge Gaps / Semantic Shifts / Precision Ledger yes

BriefingView.tsx only listens for a standing-query-matches event to render a toast; nothing can create, list or delete a standing query. The paid page markets features a customer cannot use. Either the UI gets built or the marketing gets pulled — that is a product call, so it is flagged here rather than papered over.

Related correction: #421 reported "IPC health 100%, 0 ghosts." That was measured before #425 fixed the detector ("119 dead IPC commands were invisible"). The real figure on main is 69.6%. Do not quote the 100%.

What this deliberately does NOT do

No always-on invariants workflow. I built one, then found #429 had already landed a Repo guards job covering file sizes, window spawns and release channel — citing the same 2026-08-14 outage. Duplicating it would be noise, so mine was deleted.

check-remove-by still needs one line in that Repo guards job. .github/workflows/validate.yml is claimed by the lane that shipped #429 and they are still ahead of main, so it is theirs to add rather than mine to conflict with.

Verification

  • check-remove-by exits 0
  • ghost gate: 13 new → 0
  • cargo test --lib: 4300 passed, 0 failed
  • cargo fmt --check: clean
  • Exit codes read from cargo directly — piping through tail returns tail's status and will report a failed build as success.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WJ6BP3GX5HYrnjW1nGtrvC

…he ghost gate

Two pre-existing blockers, both surfaced by trying to wire a gate that had
never been wired.

**scripts/check-remove-by.cjs was connected to nothing.** Not package.json,
not husky, not CI. It shipped in #421 — mine — to enforce the repo's own
"REMOVE BY" contract, and has sat inert since. Running it by hand found one
deadline that fell due TODAY: osv::Affected::versions, REMOVE BY 2026-08-15.

Removed the field. Verified inert first: no `.versions` read anywhere in
src-tauri/src/osv/, and matching runs off `affected_ranges` through
check_version_affected, which falls back to "assume affected" when it cannot
decide — so an advisory carrying only `versions` still alerts and dropping
the field costs no coverage. Serde ignores unknown fields, so the wire format
is unchanged. Five constructors set it to None (one production merge path in
sync.rs, four fixtures); all removed.

**The ghost gate blocked every Rust-touching commit.** #425 broke open a
closed gate loop and seeded a 119-entry backlog on 2026-08-14, but the seed
predates the post-#421 tree, so 13 commands were left unallowlisted and the
gate failed on main for anyone staging Rust.

Those 13 are not a #421 regression, and the entries say so. Each was orphaned
when its only caller was deleted — but that caller was a component nothing
mounted (ConvergenceTab was referenced solely by itself and its own test), so
the feature was already unreachable before #421 removed the dead code. The
detector simply could not see it until #425 fixed it.

Added them to the documented backlog so the gate blocks NEW regressions
again, with a reason recording what they actually are — because allowlisting
is not the fix here: several back Signal-tier features that /signal still
markets (Standing Queries, Cross-Project Intelligence). Those need UI built
or the marketing pulled. Tracked, not buried.

No always-on invariants workflow: #429 already landed a "Repo guards" job
covering file sizes, window spawns and release channel, citing the same
2026-08-14 outage. Duplicating it would be noise. check-remove-by still needs
one line in that job — validate.yml is claimed by that lane, so it is theirs.

Verified: check-remove-by exits 0; ghost gate 13 new -> 0; cargo test --lib
4300 passed / 0 failed; cargo fmt --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJ6BP3GX5HYrnjW1nGtrvC
@runyourempire
runyourempire merged commit a8365d7 into main Aug 14, 2026
16 of 18 checks passed
@runyourempire
runyourempire deleted the fix/osv-remove-by-and-ghost-backlog branch August 14, 2026 16:34
runyourempire added a commit that referenced this pull request Aug 15, 2026
… its own maintenance (#462)

## The defect

`source_items_fts` is an **external content** FTS5 table
(`content='source_items'`). SQLite
stores only the inverted index and reads the text back out of
`source_items`, so it
maintains **nothing** automatically — every write has to be mirrored in
by hand. Three
paths never were:

| Path | What it did |
|---|---|
| `batch_upsert_pending_source_items` | Wrote `title`/`content` with
**no FTS statement at all** — any item whose embedding failed was
unsearchable. |
| `cleanup_old_items`, `run_maintenance`, `prune_noise`,
`trg_source_items_cascade_delete` | Removed rows from `source_items` and
left their postings behind. **No FTS `'delete'` existed anywhere in the
repository.** |
| `upsert_source_item`, `batch_upsert_source_items` | Used `INSERT OR
REPLACE` **after** updating `source_items`. On an external-content table
the implicit REPLACE-delete reads the old values back from the content
table — which already held the NEW text — so it removed the new postings
and stranded the old. |

The third one was not in the original report and is the dominant cause
on the live corpus:
it runs on every re-fetch of an item whose text changed.

### Measured on a read-only snapshot of the live 247 MB corpus

```
BEFORE  integrity-check rank0=PASS  rank1=FAIL(database disk image is malformed)
        2,631 divergent terms  |  2,148 stale + 2,630 missing vocab rows  |  38 items affected
AFTER   integrity-check rank0=PASS  rank1=PASS
```

Rank matters, and it is why this went unnoticed. On an
**external-content** table
`('integrity-check', 0)` checks only that the index's own b-trees are
well formed — it
**passes** on the corrupted corpus. Only `('integrity-check', 1)`
recomputes the index
checksum from `source_items`. `PRAGMA quick_check`, which
`Database::new` runs at every
startup, also passes.

The user-visible symptom, reproduced on real rows: searching a word that
had been *edited
out* of an item still returns that item.

## The fix

**Schema 104** replaces all hand-maintenance with
`trg_source_items_fts_{insert,update,delete}`
and rebuilds the index once. The three manual FTS statements are
deleted. Measured
end-to-end on a copy of the live database, the whole `Database::new`
path — pre-migration
backup copy of 247 MB included — took **5.9 s**.

A trigger is the right home for two reasons: no future call site can
forget it, and the
`'delete'` command needs the pre-update values that only `OLD.*` still
has.

Two deliberate narrowings on the UPDATE trigger make this **strictly
less** write
amplification than what it replaces:

- `OF title, content` — the scoring drain stamps thousands of
`relevance_score` updates
  per run and now touches the index not at all.
- `WHEN OLD.title IS NOT NEW.title OR OLD.content IS NOT NEW.content` —
a re-fetch that
rewrites identical text (the common case) does no index work, where the
unconditional
  `INSERT OR REPLACE` always did.

**Migration, not startup repair.** A guarded startup repair would need
`integrity-check`
rank 1 — a full tokenizing scan of the content table — on every launch
of both the GUI and
the engine, and it would grow linearly with the corpus. The migration
runs exactly once,
inside the existing transactional + backed-up +
`migration_history`-recorded framework.
`fts_integrity_check()` and `rebuild_fts_index()` are exposed for
diagnostics and tests.

## Also in this PR

**Defect 2 — headless engine had no DB maintenance.**
`run_scheduled_maintenance` now runs
at the end of every headless cycle, at the end of a drain, and every 10
drain cycles.
Every previous caller lived in the GUI monitoring loop, so a
headless-only day did all of
the writing and none of the upkeep — `scheduler_state` froze at
2026-08-12 13:54 while
`fourda-engine` wrote until 08-13 14:05 with zero checkpoints. The
TRUNCATE gate drops
50 MB → 16 MB, a size the engine can actually reach: `wal_autocheckpoint
= 1000` at a
4 KiB page churns around 4 MB, so an un-truncated WAL sat between the
two numbers
indefinitely (25.9 MB when reported, 47.7 MB a day later).

**Defect 3 — backup pruning.** The pruner understood only
`<db>.backup.vN`, so two
unbounded families grew forever: hand-made `.bak-pre-*` snapshots and
`.db.corrupt-<unix>`
quarantine copies (the corruption-recovery path writes one per
incident). It now keeps the
newest 2 of **each family independently**, where the retention unit for
a hand-made
snapshot is the snapshot *plus its `-wal`/`-shm` siblings* — a database
restored without
its WAL, or a WAL without its database, is worse than neither. (The
first cut of this
counted the sibling as its own slot; my own
`families_are_pruned_independently` test caught
it splitting pairs, and there is now a test pinning the pairing rule.)

Conservative by construction: matched strictly against this database's
own file name, an
unparseable suffix is left alone, a file whose mtime cannot be read
sorts newest rather
than oldest, `4da.db.bakery` is not mistaken for a backup, and the
just-written backup is
protected. The retention rule is extracted as a pure function with tests
— the previous
prune bug survived precisely because the rule was only reachable through
real `read_dir`
output. **Nothing is deleted by this PR**; the pruner is merely made
capable.

**Defect 4 — partial.** `prune_orphaned_project_dependencies` is
restructured into
read / stat / write phases so `std::fs::metadata` no longer runs with a
transaction open
(one dead mapped network drive pinned a WAL snapshot for a full SMB
timeout, stalling
checkpointing), and its delete phase opens `BEGIN IMMEDIATE` so
contention becomes a
bounded wait instead of an un-retryable `SQLITE_BUSY_SNAPSHOT` on lock
upgrade.

The broader `retry_on_busy` + `BEGIN IMMEDIATE` sweep across the
codebase is **not** done —
see "Left untouched" below.

## Verification

**The new tests are real.** Restored the pre-fix `sources.rs` and
`migrations.rs`, kept the
final set of tests, and re-ran: **8 of 9 fail**, each with the symptom
it names.

```
pending_embedding_items_are_indexed_for_search      left: 0, right: 1
reupserting_retires_the_terms_...                   a title term that was edited out must stop matching
retention_delete_removes_the_items_postings         a deleted item must leave no postings behind
batch_upsert_indexes_inserts_and_updates            the batch update path must retire replaced terms too
every_delete_path_keeps_the_index_consistent        prune_noise must not strand postings
hybrid_search_bm25_leg_tracks_the_current_text      the keyword leg must not still match text the item no longer contains
rebuild_repairs_an_index_that_diverged_...          FAILED
fresh_database_installs_the_fts_triggers            left: [], right: [3 triggers]

scoring_updates_do_not_disturb_the_index            ok   (asserts a property that already held)
```

**The real migration was run over the real corpus.** A throwaway
in-crate test (not
committed) pointed `Database::new` at a copy of the live snapshot, so
the whole Rust path
ran — sqlite-vec registration, pre-migration backup, schema 103 → 104,
the new pruner:

```
MIGRATED 12273 items to schema 104 in 5.87s; source_vec rows=12273   -> integrity-check rank1 PASS
RETENTION deleted 12273 real rows                                    -> integrity-check rank1 PASS
BACKUPS v101=pruned v102=kept v103=kept  bak-pre-v7=kept bak-pre-v8=kept
        corrupt=kept  settings.json.bak-pre-keepme=kept
```

The Python-level verification could not cover this: stock `sqlite3`
cannot load `vec0`, so
nothing touching `source_vec` was exercised there.

Gates: `cargo fmt --check` clean · `cargo clippy -- -D warnings` clean
for **both** default
and `--features experimental` · `check-file-sizes`,
`check-doc-location`,
`private-asset-guard`, `ghost-commands`, `compound-quality-check` and
`validate-translations` clean.

`cargo test --tests` — **4,640 passed / 0 failed / 13 ignored** across
all 9 binaries:

| binary | passed |
|---|---|
| `fourda_lib` (lib) | 4,316 (baseline 4,300; +16 new) |
| `stack_simulation` | 124 |
| `victauri_dogfood` | 157 |
| `pipeline_integration` | 13 |
| `cli` | 13 |
| `migration_tests` | 12 |
| `source_resilience` | 5 |
| `fourda`, `fourda-engine` | 0 (no tests) |

**The operator's live database was never opened for writing.** All
measurement was done on
snapshots taken with SQLite's online backup API (a plain file copy of a
live WAL database
tears).

### Two things worth knowing about the machine, not this PR

⚠️ **D: hit 0.03 GB free mid-run and killed a link** (`os error 112`) —
Defect 3's exact
failure mode, arriving on its own. 14 worktrees hold ~230 GB of
`src-tauri/target` between
them, and `cleanup-orphaned-worktrees.cjs --execute` reclaims none of it
(0 dirs removable;
it only has 8 merged *branches* to delete). Space was freed and every
suite re-run.

⚠️ **A stale worktree base looks exactly like a code regression.** This
branch was cut from
`c1fd348c`; by commit time `origin/main` was 6 commits ahead, and the
pre-commit ghost gate
failed with *13 new ghost commands in files this PR never touches*. The
cause was that
`scripts/ghost-command-backlog.json` had gained 13 entries upstream
(#434 — "unblock Rust
commits on the ghost gate"). Rebasing onto current `main` cleared it.
Worth a note in the
worktree docs: **re-fetch before committing**, because that failure
reads as your fault.

## Left untouched

**The `retry_on_busy` helper + `BEGIN IMMEDIATE` sweep (rest of Defect
4).** Applying
`BEGIN IMMEDIATE` and a retry wrapper to the codebase's
`unchecked_transaction()` call
sites is a large, uniform change to locking behavior, and doing it to
*some* write paths is
worse than doing it to none — mixed deferred/immediate writers can
deadlock in ways neither
does alone. It wants its own PR with a contention benchmark. The one
site fixed here was
fixed because it had a specific, independently-diagnosed bug (blocking
I/O inside a
transaction) rather than as a partial sweep.

**`busy_timeout` left at 5000 ms.** Raising it is a one-line change that
would reduce
`SQLITE_BUSY`, but it trades errors for UI stalls of up to the new
timeout, and I have no
contention measurement to size it from. Deliberate non-change.

**The 262 ad-hoc `Connection::open` call sites** are out of scope as
instructed.

---

**Nothing on the operator's machine was deleted or mutated.** The
migration will run on
first launch after merge; it writes `4da.db.backup.v103` first (existing
behavior) and the
whole step measured 5.9 s on a copy of the current corpus.

Note for activation: both binaries need rebuilding before the migration
takes effect —
`fourda.exe` and `fourda-engine.exe` each run `Database::new`, and
whichever starts first
performs the migration.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
runyourempire added a commit that referenced this pull request Aug 15, 2026
…destroys the corpus) (#464)

## Opening a newer database with an older 4DA build destroys the user's
corpus

Found while rehearsing the schema-104 activation from #462 on a **copy**
of the founder's
live database. Measured, not inferred.

The 2026-08-14 build opened a schema-104 database. The migration guard
in `migrations.rs`
correctly refused it. `get_database()`'s last-resort fallback then read
that refusal as
corruption:

```
WARN  4da::db: Database open failed after preemptive recovery — last-resort fallback
      error=Database schema version 104 is newer than this version of 4DA supports (max 103).
INFO  4da::db: Corrupt database preserved, creating fresh database
      corrupt="…\4da.db.corrupt"
INFO  4da::db: Running Phase 1: multi-format files (schema version 1 -> 2)
```

Before / after, same directory:

| file | size | schema | source_items |
|---|---|---|---|
| `4da.db` (what the app now uses) | 1.3 MB | 103 | **0** |
| `4da.db.corrupt` (the real corpus) | 283 MB | 104 | **15,659** |

The app comes up empty and starts re-fetching from zero. One log line is
the only trace.
**Every rollback to a previous release does this** — and on this fleet
it needs no rollback
at all, because the scheduled background refresh runs whatever was last
compiled into
`target/debug/fourda.exe`.

The guard itself has been correct since 2026-03-29. The bug is entirely
in how the caller
classifies its error.

## The fix

**1. A schema-too-new error is routed away from the corrupt-db
fallback.** `state.rs` now
bails out and returns the error, mirroring the
`is_database_lock_contention` bail-out
directly above it — that precedent already existed for exactly this
shape of problem.

The detector keys on **both** `SQLITE_MISMATCH` and a phrase shared with
the producer via
`SCHEMA_TOO_NEW_PHRASE`, so:
- an unrelated `SQLITE_MISMATCH` cannot suppress genuine corruption
recovery, and
- producer and detector cannot drift apart.

Getting this wrong in either direction is expensive: too narrow and the
corpus is
destroyed; too broad and a genuinely corrupt database never heals. Both
directions are
tested.

**2. Quarantine copies are no longer auto-pruned.** #462 added
`*.db.corrupt` /
`*.db.corrupt-<unix>` to the backup pruner to reclaim disk. That was my
change and it was
unsafe: a quarantined database is the user's only copy of that data, and
— per the bug
above — can be their entire live corpus. Reclaiming 338 MB is not worth
a chance of
deleting 15,659 items.

They stay *classified* so the pruner can report the disk they hold; only
`*.db.backup.vN`
and hand-made `*.bak-*` snapshots are collected.

**3. The guard gets tests.** It had none in ~5 months. A future schema
is refused with an
error that says why; a database at the current schema still reopens
cleanly with a
consistent FTS index (so the guard cannot pass by being indiscriminate);
and one test
asserts **end-to-end that the error the guard actually produces is the
one the detector
recognises** — testing them apart would let them drift and silently
re-arm the
corpus-destroying path.

## Also

Documents the two skew traps in CLAUDE.md's gotchas. Both cost real time
this week and both
present as your own bug:

- **Old binary vs. newer database** (above) — migrate and rebuild
together.
- **Stale worktree base** — `main` moved 6 commits during one agent
session, after which
the pre-commit ghost gate failed citing 13 "NEW" ghost commands in files
the branch never
touched. They had simply been allowlisted upstream in #434. Re-fetch and
rebase before
committing; if a gate blames code you did not write, check your base
before you touch an
  allowlist.

## Verification

`cargo fmt --check` clean · `cargo clippy -- -D warnings` clean for
**both** default and
`--features experimental` · `cargo test --lib` **4,378 passed / 0 failed
/ 8 ignored**.

The founder's live database was **not** migrated and **not** written to.
All of the above
was measured on copies taken with SQLite's online backup API. That
decision is the point of
this PR: activating #462 before the binaries are rebuilt would have
destroyed the corpus on
the next scheduled refresh.

## Activation, in the right order

With this merged, activation is safe and is a single ordered operation:

1. `git pull` in `D:\4DA`
2. `cd src-tauri && cargo build --bin fourda --bin fourda-engine`
3. launch — the migration runs, rebuilding the FTS index (447 ms on the
15,659-item corpus)

Step 2 before step 3 is the whole rule. Doing 3 with a stale step 2 is
what this PR makes
survivable.

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