Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed — `matched` was an opinion, and eleven tables that do not exist got it

`sync_status` is the word a customer reads on the code↔DB screen. `matched` says *your
code and your database agree about this table*. Measured in production on 2026-08-27:

db_index rows for bs ls os zes active unlimit esim
clones interfaces price contract → 0
code_db_sync status for those same eleven → matched, all of them

**Eleven tables that are not in the database were reported as agreeing with it.**

The mechanism, not a guess. `_match_tables` builds the code-only tail with
`_make_matched(nm, "", …)` — `db_context` empty, because there is no DB side. That struct
goes to the LLM, which returns a `sync_status`. The deterministic SYNC-L5 override only
fires when **both** column sets are non-empty, which a table with no DB side can never
satisfy — so `effective_status = analysis.sync_status` stood and the model's answer became
the customer's fact.

`resolve_sync_status()` now decides in this order:

1. **Structure.** No DB side → `code_only`. No code side → `db_only`. A missing side is a
fact; no model can overturn it and none should be asked to.
2. **SYNC-L5**, where the column sets settle `matched` versus `mismatch`.
3. **The model**, in the one case left — both sides present, columns unknown on one of
them. That is where its reading is the only reading available.

The fix removes judgement from where it does not belong and leaves it where it does: a
genuine pair with a code-only column still resolves to `mismatch`, and a clean pair still
resolves to `matched`, both without asking.

23 tests, the eleven names among them verbatim. It never raises — it runs inside the sync
pipeline, and losing a table's row to an exception is worse than an uncertain status.

### Fixed — the table-name guard did not apply on the way in, so the store never cleaned

Deploying the guard was not enough, and measuring after the deploy is how that was found.
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ User rules in `rules/` (or `CUSTOM_RULES_DIR`) are injected into orchestrator an

Read-only Git operations on the project's local clone (`git_agent.py`, `GitInspector`): commits, diffs, blame, releases, file churn. Gated by `has_repo` probe; path-traversal guard, output/count caps, no hooks. Freshness warning when clone lags indexed HEAD; optional `git_agent_auto_pull`. Findings persist as `code_finding` insights. Roadmap: `docs/GIT_ACCESS_AUDIT_AND_ROADMAP.md`.

### Code↔DB `sync_status`: structure outranks the model

`matched` claims both sides exist, so that precondition is checked before anything is
asked. `resolve_sync_status()` (`code_db_sync_pipeline.py`) decides in order: no DB side →
`code_only`, no code side → `db_only`; then SYNC-L5's column arithmetic settles `matched`
versus `mismatch`; then — and only when both sides exist with columns unknown on one — the
LLM's reading stands.

Before 2026-08-27 the model decided by default. `_match_tables` builds the code-only tail
with an empty `db_context`, SYNC-L5 needs both column sets and so never fired for those
rows, and eleven tables with **no `db_index` row at all** were stored as `matched` in
production — `bs`, `zes`, `esim`, `interfaces` among them.

### Code↔DB table names: a declaration outranks a guess

The link between a repository and a database rests on knowing which tables the code uses.
Expand Down
90 changes: 68 additions & 22 deletions backend/app/knowledge/code_db_sync_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,28 +347,26 @@ async def _one_small_batch(batch_list):
# are known (non-empty column sets), so we never flip
# "code_only" or "db_only" tables to "mismatch" when
# one side has no column information.
effective_status = analysis.sync_status
try:
drift = json.loads(mt.column_mismatch_json)
has_code_cols = bool(drift.get("code_only") or drift.get("matched"))
has_db_cols = bool(drift.get("db_only") or drift.get("matched"))
if has_code_cols and has_db_cols:
if drift["code_only"] or drift["db_only"]:
effective_status = "mismatch"
else:
effective_status = "matched"
if effective_status != analysis.sync_status:
logger.debug(
"SYNC-L5 override: %s LLM=%s → det=%s "
"(code_only=%s db_only=%s)",
analysis.table_name,
analysis.sync_status,
effective_status,
drift["code_only"],
drift["db_only"],
)
except (json.JSONDecodeError, KeyError, TypeError):
pass # leave LLM opinion intact on parse error
# Structure decides before the model does: a table with no
# DB side cannot be `matched`, whatever was said about it.
# See `resolve_sync_status` — SYNC-L5's column arithmetic
# lives in there too, so this call is the whole decision.
effective_status = resolve_sync_status(
llm_status=analysis.sync_status,
db_context=mt.db_context,
has_code_info=mt.has_code_info,
column_mismatch_json=mt.column_mismatch_json,
)
if effective_status != analysis.sync_status:
logger.debug(
"sync_status override: %s LLM=%s → %s "
"(db_side=%s code_side=%s)",
analysis.table_name,
analysis.sync_status,
effective_status,
bool((mt.db_context or "").strip()),
mt.has_code_info,
)

sync_data = {
"table_name": analysis.table_name,
Expand Down Expand Up @@ -968,6 +966,54 @@ def _build_project_context(knowledge: ProjectKnowledge) -> str:
return "\n".join(parts)


def resolve_sync_status(
*,
llm_status: str,
db_context: str,
has_code_info: bool,
column_mismatch_json: str,
) -> str:
"""The status a customer reads, decided structurally before it is decided by a model.

``matched`` says *your code and your database agree about this table*. That claim has
a precondition — both sides must exist — and the precondition is a fact, not a
judgement. Measured in production on 2026-08-27: eleven tables with **no `db_index`
row at all** were stored as ``matched``, among them `bs`, `zes`, `esim` and
`interfaces`. `_match_tables` builds the code-only tail with an empty ``db_context``
(see the ``_make_matched(nm, "", …)`` call), the SYNC-L5 override needs *both* column
sets to fire and so never does for such a table, and the model's answer stood.

The order here is deliberate:

1. **Structure first.** No DB side → ``code_only``. No code side → ``db_only``. No
model can overturn a missing side, and no model should be asked to.
2. **Then SYNC-L5**, where the column sets settle ``matched`` versus ``mismatch``.
3. **Then the model**, in the one case left: both sides present, columns unknown on
one of them. That is where its reading is the only reading available.

Never raises. It runs inside the sync pipeline, and losing a table's row to an
exception is worse than keeping a status that is merely uncertain.
"""
has_db_side = bool((db_context or "").strip())
if not has_db_side:
return "code_only" if has_code_info else "db_only"
if not has_code_info:
return "db_only"

try:
drift = json.loads(column_mismatch_json)
code_only = drift["code_only"]
db_only = drift["db_only"]
matched = drift["matched"]
has_code_cols = bool(code_only or matched)
has_db_cols = bool(db_only or matched)
if has_code_cols and has_db_cols:
return "mismatch" if (code_only or db_only) else "matched"
except (json.JSONDecodeError, KeyError, TypeError):
pass # the columns cannot settle it; the model's reading stands
return llm_status


class _MatchedTable:
"""Internal struct for a table matched between code and DB."""

Expand Down
178 changes: 178 additions & 0 deletions backend/tests/unit/knowledge/test_matched_requires_both_sides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""`matched` means present on both sides. It cannot be an opinion.

`sync_status` is the word a customer reads on the code↔DB screen. `matched` says *your
code and your database agree about this table*. Measured in production on 2026-08-27:

db_index rows for bs ls os zes active unlimit esim clones
interfaces price contract → 0
code_db_sync status for those same eleven names → matched, all of them

Eleven tables that **do not exist in the database** were reported as agreeing with it.

The mechanism, not a guess. `_match_tables` builds the code-only tail with
``_make_matched(nm, "", …)`` — `db_context` empty, because there is no DB side
(`code_db_sync_pipeline.py:643`). That struct goes to the LLM, which returns a
`sync_status`. The deterministic SYNC-L5 override only fires when **both** column sets are
non-empty, which a table with no DB side can never satisfy — so
``effective_status = analysis.sync_status`` stands, and the model's answer becomes the
customer's fact.

The rule that replaces it needs no model and no judgement:

* no DB side → `code_only`
* no code side → `db_only`
* both → the LLM may distinguish `matched` from `mismatch`, and SYNC-L5 overrides
it when the column sets settle the question

That leaves the LLM exactly where its judgement cannot be replaced, and nowhere else.
"""

from __future__ import annotations

import json

import pytest

from app.knowledge.code_db_sync_pipeline import resolve_sync_status

#: The eleven names, verbatim from production. None had a `db_index` row; all were
#: `matched`.
CODE_ONLY_IN_PRODUCTION = [
"bs",
"ls",
"os",
"zes",
"active",
"unlimit",
"esim",
"clones",
"interfaces",
"price",
"contract",
]

BOTH_SIDES = json.dumps({"code_only": ["a"], "db_only": [], "matched": ["b"]})
CLEAN_BOTH_SIDES = json.dumps({"code_only": [], "db_only": [], "matched": ["b"]})
ONE_SIDE = json.dumps({"code_only": [], "db_only": [], "matched": []})


class TestATableWithNoDatabaseSideIsCodeOnly:
@pytest.mark.parametrize("name", CODE_ONLY_IN_PRODUCTION)
def test_the_eleven_from_production(self, name: str) -> None:
"""Whatever the model said. `db_context` empty is not a matter of opinion."""
assert (
resolve_sync_status(
llm_status="matched",
db_context="",
has_code_info=True,
column_mismatch_json=ONE_SIDE,
)
== "code_only"
)

def test_even_when_the_model_says_mismatch(self) -> None:
"""`mismatch` is as wrong as `matched` here — it also claims a DB side exists."""
assert (
resolve_sync_status(
llm_status="mismatch",
db_context="",
has_code_info=True,
column_mismatch_json=ONE_SIDE,
)
== "code_only"
)


class TestATableWithNoCodeSideIsDbOnly:
def test_regardless_of_the_model(self) -> None:
assert (
resolve_sync_status(
llm_status="matched",
db_context="Column notes: id, name",
has_code_info=False,
column_mismatch_json=ONE_SIDE,
)
== "db_only"
)


class TestWithBothSidesPresentNothingChanges:
"""The fix must not take judgement away where judgement is what is needed. With a DB
side and a code side, `matched` versus `mismatch` is a real question, and SYNC-L5
already answers it deterministically when the column sets are known."""

def test_sync_l5_still_overrides_to_mismatch(self) -> None:
assert (
resolve_sync_status(
llm_status="matched",
db_context="Column notes: id, name",
has_code_info=True,
column_mismatch_json=BOTH_SIDES,
)
== "mismatch"
), "a column the code has and the DB does not is a mismatch, whatever the model said"

def test_sync_l5_still_overrides_to_matched(self) -> None:
assert (
resolve_sync_status(
llm_status="mismatch",
db_context="Column notes: id, name",
has_code_info=True,
column_mismatch_json=CLEAN_BOTH_SIDES,
)
== "matched"
)

def test_the_model_is_kept_when_the_columns_cannot_settle_it(self) -> None:
"""Both sides exist but one has no column information — the one case where the
model's reading is the only reading available."""
for said in ("matched", "mismatch"):
assert (
resolve_sync_status(
llm_status=said,
db_context="Column notes: id",
has_code_info=True,
column_mismatch_json=ONE_SIDE,
)
== said
)


class TestItSurvivesBadInput:
"""`resolve_sync_status` runs inside the sync pipeline. Raising there loses the whole
table's row, which is worse than keeping an uncertain status."""

@pytest.mark.parametrize("bad", ["", "not json", "[]", "null", '{"code_only": 3}'])
def test_unparseable_drift_falls_back_to_the_model(self, bad: str) -> None:
out = resolve_sync_status(
llm_status="matched",
db_context="Column notes: id",
has_code_info=True,
column_mismatch_json=bad,
)
assert out == "matched"

def test_a_missing_db_side_still_wins_over_bad_drift(self) -> None:
"""The side check is structural and does not depend on parsing anything."""
assert (
resolve_sync_status(
llm_status="matched",
db_context="",
has_code_info=True,
column_mismatch_json="not json",
)
== "code_only"
)

def test_neither_side_is_not_matched(self) -> None:
"""Should not happen, and if it does, `matched` is the one answer that is
certainly wrong."""
assert (
resolve_sync_status(
llm_status="matched",
db_context="",
has_code_info=False,
column_mismatch_json=ONE_SIDE,
)
!= "matched"
)
Loading