Skip to content

Commit af4033a

Browse files
committed
fix: stacked-review round — 1 Critical, 3 High, 3 confirmed handle-shape defects (nexus-dhs30, asaod, ingey, at2ff sweep)
Six reviewers over 2b2ecd0..a7ef414, run AFTER those eight commits shipped — which is how the Critical below reached develop and sat there. Every fix here is mutation-verified: the guard is removed, the test fails, the guard is restored. CRITICAL — CI has been RED on develop for hours and nobody looked (nexus-dhs30) a797dbd added `fetch-tags: true` to release.yml but NOT to ci.yml's `test` job, which runs the full suite on every push from a shallow, tagless checkout. The non-vacuity test I had just written (test_newest_published_engine_reads_ real_tags) therefore failed on EVERY push from a797dbd onward — four commits landed on red. Each of those commit messages says "Full unit suite: N passed, 0 failed", true from a local full clone, and the CI result was never read. Exactly the rot class the day was spent mechanizing against, one layer down: the mechanization's own test was never validated in the shape it runs in. - ci.yml `test` job gains fetch-tags: true. - The test is SPLIT: a new hermetic test builds its own repo with known tags, so the parse bug it exists to catch (parse_engine_version takes "0.1.56", not "engine-service-v0.1.56") is caught in ANY environment, tags or not. HIGH — the asaod 409 guard was half-applied, and the missed half is the heavier path. `op.startsWith("/import/")` does NOT match "/import_batch" (no slash after "import"). importTopicsBatch does the identical insertInto(TOPICS, TOPICS.ID, ...).onConflict(TOPICS.ID) against the same global BIGSERIAL PK, so the bulk ETL route — the 190k-row dogfood leg — kept returning the opaque 500 the fix was cut to remove. Extracted isImportOp(); added a Testcontainers regression test that fails against the original guard. NOT LIVE: engine-service-v0.1.56 is deployed with the half-applied guard. HIGH x2 — `nx doctor` crashed where it was supposed to degrade, in the very commit that existed to stop doctor misreporting health (nexus-ingey/k0luu). _report_aspect_queue_service caught only httpx.HTTPError, but store CONSTRUCTION resolves the endpoint and raises ServiceEndpointUnresolvableError — a RuntimeError, not an httpx error. _run_trim_telemetry's service branch had NO handling at all. A missing supervisor lease or absent NX_SERVICE_TOKEN thus produced a traceback. The console twin written in the SAME commit caught (httpx.HTTPError, RuntimeError) correctly; the doctor sites did not. Trim now exits 2 with UNKNOWN rather than reporting a partial trim as complete. THREE MORE at2ff INSTANCES, found by an independent sweep beyond the four fixed in a23b2aa: - db/migrations.py:2705 — an UPGRADE BLOCKER. t3_db._client made every collection print "SKIPPED (AttributeError)", then a raw taxonomy.conn read OUTSIDE the loop's except hard-failed, so `nx upgrade` reported "will retry on next nx upgrade" for a retry that could never succeed. Both halves fixed; the raw count is DELETED rather than guarded (it was a progress-line nicety, and guarding it would have grown a census that may only shrink). - db/t3_reidentify.py:145 — `nx t3 reidentify` errored on every collection. - mcp/plan_cache_registry.py:62 — the staleness tier is DEAD in production (HttpPlanLibrary has no .path, so mtime is always 0.0). DECLARED, not faked: a real fix needs a server-side ETag. The docstring had called the only production case an "edge case". A THIRD TEST FOUND ENCODING A PHANTOM HANDLE SHAPE. tests/test_projection_ quality.py had its own `class _StubT3: self._client = client`, and tests/mcp/test_remediate_tool.py stubbed a telemetry object with no record_consent while calling it "the real service-mode shape" — but HttpTelemetryStore has had record_consent since nexus-ng2sy. Three today (cf. tests/test_catalog.py). Each made a permanently-dead branch look covered. ALSO FIXED: - nexus-xj744: HttpCatalogClient._db raised RuntimeError, violating the guard contract that hasattr()/has_raw_access() depend on. Now AttributeError, plus a NEW contract suite pinning all nine service-backed stores so the next one cannot reintroduce it. - nexus-huaef: the remediate version-skew branch could never fire. The fail-closed contract itself was never at risk (record_consent raises via _raise_for_status, and the generic except refuses), but the actionable "upgrade the engine" diagnosis was dead. Now keyed on 404/405, with a 500 deliberately NOT misdiagnosed as skew. - nexus-d4ac1: embed_migrate's two at2ff sites, fixed while still dead code. - The ManagedServiceError branch of check_floor had ZERO coverage — replacing it with `return 0` left all 14 tests green. Covered. - NEXUS_PREV_RELEASE/NEXUS_PREV_ENGINE_TAG rotated to 6.18.0/v0.1.52. This was a MISSED SCHEDULED TRIGGER, not new debt: the 6.18.0 record said "rotate on next floor bump" and that bump was bfd3c25. - M1 accepted risk recorded in code: the 409-vs-200 status is a cross-tenant id-EXISTENCE oracle (global BIGSERIAL PK). Body and logs leak nothing further. Enumeration surface left open — nexus-4fpbl (P3). - Locale coupling on the RLS message match noted (non-English lc_messages silently degrades every 409 back to 500). The suite-runtime anomaly (1:03:13 vs ~13min) was diagnosed as machine contention, NOT code — the routing tests run 43 tests in 2.1s and the tree runs at normal throughput today. --durations=15 added to the invocation so the next anomaly localizes itself instead of needing forensics. Full unit suite: 13,432 passed, 0 failed (fresh service JAR).
1 parent a7ef414 commit af4033a

17 files changed

Lines changed: 641 additions & 58 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ jobs:
8585

8686
steps:
8787
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
88+
with:
89+
# nexus-dhs30: the suite includes a NON-VACUITY test that reads
90+
# `git tag -l engine-service-v*` (tests/scripts/
91+
# test_check_engine_release_floor.py::test_newest_published_engine_reads_real_tags).
92+
# Default checkout is shallow and fetches NO tags, so that test saw an
93+
# empty tag list and failed on every push from a797dbd4 onward — four
94+
# commits landed on red CI because the local full-clone run was green
95+
# and nobody read the CI result. release.yml got `fetch-tags: true` in
96+
# the same commit; this job did not.
97+
fetch-tags: true
8898

8999
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
90100
with:

service/src/main/java/dev/nexus/service/http/HttpUtil.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ public static boolean sendTypedDbError(HttpExchange exchange, Throwable e,
188188
* the previous 500 — wrong status, never a wrong success. The paired
189189
* ``rejectsCrossTenantIdWith409`` test pins the live wording so the coupling
190190
* cannot rot silently.
191+
*
192+
* <p>LOCALE COUPLING (review, 2026-07-25): the message match assumes the PG
193+
* server reports in English. A server with a non-English {@code lc_messages}
194+
* localises "row-level security policy", the match silently fails, and every
195+
* RLS refusal degrades back to an opaque 500 — the exact defect this exists to
196+
* remove, reappearing as a config-dependent regression rather than a crash.
197+
* Acceptable for a controlled hosted instance; state it rather than rediscover
198+
* it. Same fragility class as a future PG rewording.
191199
*/
192200
public static boolean isRlsRowRejection(Throwable t) {
193201
Throwable c = t;

service/src/main/java/dev/nexus/service/http/TaxonomyHandler.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,29 @@ public void handle(HttpExchange exchange) throws IOException {
209209
// would be cross-tenant information disclosure. "not available" is all the
210210
// caller is entitled to, and all it needs (pick another id / re-key the
211211
// import).
212-
if (op != null && op.startsWith("/import/") && HttpUtil.isRlsRowRejection(e)) {
212+
//
213+
// ACCEPTED RISK, stated so it is not re-derived (review 2026-07-25, Hal
214+
// decision): the 409-vs-200 STATUS ITSELF is a weaker signal of the same
215+
// fact. nexus.topics has a GLOBAL BIGSERIAL primary key — RLS scopes rows,
216+
// not the id space — so an authenticated tenant can probe whether an
217+
// arbitrary id is claimed SOMEWHERE in the system without any read access
218+
// to the holder's data. What leaks is existence only: not the holding
219+
// tenant, not the label, not any row content (the body and the log line
220+
// above are both id-free and tenant-B-free). Probed at scale it reveals
221+
// roughly how densely the global id space is populated.
222+
//
223+
// Accepted because: the route requires Bearer auth + tenant, it is a
224+
// fidelity-ETL/migration path rather than a hot API, and ids are normally
225+
// each tenant's own prior sequential rowids — collisions happen by accident
226+
// rather than by choosing. The enumeration surface is NOT closed: there is
227+
// no rate limiting on this route (AuthFilter has none). Tracked separately.
228+
//
229+
// Removing the oracle at source would mean a composite (tenant_id, id) key
230+
// so ids cannot collide across tenants — which deletes this whole class.
231+
// That was already considered and REJECTED: topics_parent_fk is
232+
// self-referential, so a composite key forces every parent_id to carry a
233+
// tenant too. The global BIGSERIAL is deliberate, not an oversight.
234+
if (isImportOp(op) && HttpUtil.isRlsRowRejection(e)) {
213235
log.warn("event=taxonomy_import_id_unavailable tenant={} op={}", tenant, op);
214236
HttpUtil.send(exchange, 409, json(Map.of(
215237
"error", "supplied id is not available in this tenant")));
@@ -220,6 +242,32 @@ public void handle(HttpExchange exchange) throws IOException {
220242
}
221243
}
222244

245+
/**
246+
* True for the fidelity-ETL routes that preserve CLIENT-SUPPLIED ids.
247+
*
248+
* <p>Both the per-kind routes ({@code /import/topic}, {@code /import/assignment},
249+
* {@code /import/link}, {@code /import/meta}) and the BULK route
250+
* ({@code /import_batch}) insert caller-chosen primary keys, so both can be
251+
* refused by RLS with SQLSTATE 42501 when a second tenant contests an id that
252+
* {@code nexus.topics}' global BIGSERIAL PK already holds.
253+
*
254+
* <p>Extracted because the original guard was {@code op.startsWith("/import/")},
255+
* which silently EXCLUDED {@code /import_batch} — there is no slash after
256+
* "import" in that route, so the bulk path kept returning the opaque 500 the fix
257+
* was cut to remove. That is the heavier real-world path: {@code importTopicsBatch}
258+
* is the 190k-row dogfood leg, and it shares the identical
259+
* {@code insertInto(TOPICS, TOPICS.ID, ...).onConflict(TOPICS.ID)} shape as the
260+
* single-row importer. Found in review, 2026-07-25.
261+
*
262+
* <p>Not qualified by {@code kind}: the assignment/link/meta batch importers key
263+
* on tenant-scoped composite conflict targets, so a cross-tenant RLS refusal is
264+
* structurally impossible for them and {@link HttpUtil#isRlsRowRejection} simply
265+
* never fires.
266+
*/
267+
private static boolean isImportOp(String op) {
268+
return op != null && (op.startsWith("/import/") || op.equals("/import_batch"));
269+
}
270+
223271
// ── Topics handlers ────────────────────────────────────────────────────────
224272

225273
private void handleGetTopics(HttpExchange ex, String tenant, String method) throws IOException {

service/src/test/java/dev/nexus/service/http/TaxonomyHandlerImportRlsTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,32 @@ void importTopic_secondTenantClaimingSameId_returns409_not500() throws Exception
160160
assertThat(ex.bodyString()).contains("not available");
161161
}
162162

163+
@Test
164+
void importBatch_secondTenantClaimingSameId_returns409_not500() throws Exception {
165+
// nexus-asaod review H1: the fix guarded op.startsWith("/import/"), which
166+
// EXCLUDES "/import_batch" — no slash after "import". The bulk route reaches
167+
// importTopicsBatch, which does the identical
168+
// insertInto(TOPICS, TOPICS.ID, ...).onConflict(TOPICS.ID) against the same
169+
// global BIGSERIAL PK, so it raised the same 42501 and returned the same
170+
// opaque 500 the fix was cut to remove. It is also the HEAVIER real path (the
171+
// 190k-row dogfood leg), so the fix was half-applied to the less-used route.
172+
long id = CONTESTED_ID + 1;
173+
CapturingExchange seed = importTopicBatch(id, "batch-a");
174+
handleAs(TENANT_A, seed);
175+
assertThat(seed.status).as("precondition: tenant A holds the id").isIn(200, 409);
176+
177+
CapturingExchange ex = importTopicBatch(id, "batch-b");
178+
handleAs(TENANT_B, ex);
179+
180+
assertThat(ex.status)
181+
.as("bulk import must classify an RLS row refusal the same as the single-row route")
182+
.isEqualTo(409);
183+
assertThat(ex.bodyString()).contains("not available");
184+
assertThat(ex.bodyString())
185+
.as("must not leak that the id belongs to another tenant")
186+
.doesNotContain(TENANT_A);
187+
}
188+
163189
@Test
164190
void importTopic_conflictBodyDoesNotLeakTheOtherTenant() throws Exception {
165191
CapturingExchange seed = importTopic(CONTESTED_ID + 1, "leak-probe-a");
@@ -230,6 +256,22 @@ private void handleAs(String tenant, CapturingExchange ex) throws Exception {
230256
}
231257
}
232258

259+
/**
260+
* The BULK route (nexus-asaod review, H1). Same client-supplied id, same global
261+
* BIGSERIAL PK, same {@code onConflict(TOPICS.ID)} in importTopicsBatch — but a
262+
* different op string, which the original {@code startsWith("/import/")} guard
263+
* did not match.
264+
*/
265+
private static CapturingExchange importTopicBatch(long id, String label) {
266+
String row = "{\"id\":" + id + ",\"label\":\"" + label + "\","
267+
+ "\"parent_id\":null,\"collection\":\"knowledge__rls\","
268+
+ "\"centroid_hash\":null,\"doc_count\":1,"
269+
+ "\"created_at\":\"2026-07-25T00:00:00Z\","
270+
+ "\"review_status\":\"pending\",\"terms\":\"[]\"}";
271+
String body = "{\"kind\":\"topic\",\"rows\":[" + row + "]}";
272+
return new CapturingExchange("POST", URI.create("/v1/taxonomy/import_batch"), body);
273+
}
274+
233275
private static CapturingExchange importTopic(long id, String label) {
234276
String body = "{\"id\":" + id + ",\"label\":\"" + label + "\","
235277
+ "\"parent_id\":null,\"collection\":\"knowledge__rls\","

src/nexus/catalog/http_catalog_client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,17 @@ def _db(self) -> None: # type: ignore[return]
261261
262262
Bead nexus-xnz0o is a HARD BLOCKER of Phase-4 catalog deletion
263263
(nexus-gmiaf.24).
264+
265+
RAISES AttributeError, NEVER RuntimeError (nexus-xj744). ``hasattr()``
266+
only swallows ``AttributeError``; anything else propagates. A
267+
``RuntimeError`` here means a caller writing the sanctioned
268+
``hasattr(cat, "_db")`` / ``has_raw_access(cat)`` probe would CRASH in
269+
service mode instead of getting ``False`` and taking the service branch
270+
— the guard idiom that exists to make such checks safe would become the
271+
thing that breaks them. ``db/t2/_raw_handle_guard.py`` states this
272+
contract explicitly; this property was the one place violating it.
264273
"""
265-
raise RuntimeError(
274+
raise AttributeError(
266275
"catalog._db is unavailable in service mode "
267276
"(NX_STORAGE_BACKEND_CATALOG=service). "
268277
"This command path is not yet ported to the public catalog API — "

src/nexus/commands/doctor.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -593,11 +593,31 @@ def _run_trim_telemetry(days: int) -> None:
593593
# .trimSearchTelemetry / .trimHookFailures); only this call site never
594594
# routed to it. Same branch shape already used by _run_tier_writes below.
595595
if storage_backend_for("telemetry") == StorageBackend.SERVICE:
596+
import httpx # noqa: PLC0415 — deferred local import — avoids import-time cost / circular deps
597+
596598
from nexus.db.t2.http_telemetry_store import HttpTelemetryStore # noqa: PLC0415 — deferred local import — avoids import-time cost / circular deps
597599

598-
store = HttpTelemetryStore()
599-
deleted_search = store.trim_search_telemetry(days=days)
600-
deleted_hooks = store.trim_hook_failures(days=days)
600+
try:
601+
store = HttpTelemetryStore()
602+
deleted_search = store.trim_search_telemetry(days=days)
603+
deleted_hooks = store.trim_hook_failures(days=days)
604+
except (httpx.HTTPError, RuntimeError) as exc:
605+
# Same class as _report_aspect_queue_service above (review
606+
# 2026-07-25): store CONSTRUCTION resolves the endpoint and raises
607+
# ServiceEndpointUnresolvableError (a RuntimeError, not an
608+
# httpx error) when it cannot. This branch originally had NO
609+
# handling at all, so an unresolvable endpoint or a transport blip
610+
# crashed `nx doctor --trim` outright.
611+
#
612+
# Reporting nothing trimmed would be the false-clean this whole
613+
# commit exists to remove — say UNKNOWN and exit non-zero so a
614+
# scripted caller cannot mistake a failed trim for a completed one.
615+
click.echo(
616+
f"Error: telemetry trim unavailable ({exc}). Nothing was "
617+
"trimmed and the live retention state is UNKNOWN.",
618+
err=True,
619+
)
620+
raise click.exceptions.Exit(2)
601621
else:
602622
from nexus.db.t2.telemetry import Telemetry # noqa: PLC0415 — deferred local import — avoids import-time cost / circular deps
603623

@@ -644,7 +664,17 @@ def _report_aspect_queue_service() -> None:
644664
q = HttpAspectQueue()
645665
pending = q.pending_count()
646666
failed = q.list_failed()
647-
except httpx.HTTPError as exc:
667+
except (httpx.HTTPError, RuntimeError) as exc:
668+
# RuntimeError is NOT redundant with httpx.HTTPError: constructing the
669+
# store resolves the endpoint, and an unresolvable one raises
670+
# ServiceEndpointUnresolvableError, which subclasses RuntimeError and
671+
# NOT httpx.HTTPError (review 2026-07-25). Catching only the transport
672+
# error let a missing supervisor lease / absent NX_SERVICE_TOKEN escape
673+
# as a traceback out of `nx doctor` — turning a health check into a
674+
# crash, in the very commit whose purpose was to stop doctor from
675+
# misreporting health. The console twin
676+
# (console/routes/health.py::_collect_aspect_queue_data_service) had
677+
# this right; this call site did not.
648678
click.echo(
649679
f"aspect_extraction_queue: service backend unreachable ({exc}). "
650680
"Queue depth UNKNOWN — not reporting a count.",

src/nexus/db/embed_migrate.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ def detect_stale_local_collections(
124124
if not count:
125125
continue
126126
try:
127-
col = db._client_for(name).get_collection(name)
127+
# nexus-d4ac1 / nexus-at2ff: was ``db._client_for(name)``, which only
128+
# the T3Database facade has — a production HttpVectorClient handle
129+
# would AttributeError here, get swallowed by the except below, and
130+
# report ZERO stale collections. A false-clean, not a crash. Fixed
131+
# while the module is still dead code (no src/ caller today) because
132+
# the trap arms itself the moment anyone wires it up.
133+
col = db.get_collection(name)
128134
except Exception: # noqa: BLE001 — best-effort probe; skip collection on any failure
129135
continue
130136
try:
@@ -173,10 +179,12 @@ def collection_source_paths(
173179
or — post RDR-108 Phase 3 — only ``chunk_text_hash``, resolved via the
174180
catalog chash->doc_id manifest. Returns ``(source_paths, sourceless_ids)``.
175181
"""
176-
# Raw client handle: we only read metadata via ``.get()``, so we must
177-
# not attach the active EF (it would conflict with the collection's
178-
# persisted EF config for cross-embedder names — the whole point here).
179-
col = db._client_for(name).get_collection(name)
182+
# Read metadata via ``.get()`` only — no active EF attached (it would
183+
# conflict with the collection's persisted EF config for cross-embedder
184+
# names, which is the whole point here).
185+
# nexus-d4ac1 / nexus-at2ff: was ``db._client_for(name)`` (test-facade-only
186+
# attribute); the handle exposes get_collection directly on both backends.
187+
col = db.get_collection(name)
180188
source_paths: set[str] = set()
181189
sourceless: list[str] = []
182190
offset = 0

src/nexus/db/migrations.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2701,8 +2701,16 @@ def backfill_projection(t3_db: Any, taxonomy: Any) -> None:
27012701
continue
27022702
t0 = time.monotonic()
27032703
try:
2704+
# nexus-at2ff (review sweep 2026-07-25): was ``t3_db._client``.
2705+
# `nx upgrade` builds t3_db via make_t3() -> HttpVectorClient, which
2706+
# has no ``_client``; only the T3Database test facade does. The
2707+
# AttributeError was caught by the per-collection `except Exception`
2708+
# below and printed "SKIPPED (AttributeError)" for EVERY collection,
2709+
# so this backfill silently did nothing on any service-mode install.
2710+
# HttpTaxonomyStore.project_against takes the handle as its
2711+
# ``chroma_client`` argument (its own docstring says so, nexus-9pqoj).
27042712
result = taxonomy.project_against(
2705-
src, targets, t3_db._client, threshold=0.85,
2713+
src, targets, t3_db, threshold=0.85,
27062714
)
27072715
assignments = result.get("chunk_assignments", [])
27082716
# RDR-077 RF-3: 3-tuple (doc_id, topic_id, raw_cosine_similarity).
@@ -2743,13 +2751,34 @@ def backfill_projection(t3_db: Any, taxonomy: Any) -> None:
27432751
# 'attempted' counts may exceed actual writes). Lock taken per storage
27442752
# review I-1 — this runs in a long upgrade context where concurrent
27452753
# writes on the same connection are plausible.
2746-
with taxonomy._lock:
2747-
actual_written = taxonomy.conn.execute(
2748-
"SELECT COUNT(*) FROM topic_assignments WHERE assigned_by = 'projection'"
2749-
).fetchone()[0]
2754+
# nexus-at2ff: this raw read is the SECOND half of the same breakage, and
2755+
# fixing only the first leaves the step still unable to pass. On a
2756+
# service-backed taxonomy store RawHandleGuardMixin raises AttributeError
2757+
# here, OUTSIDE the per-collection except above, so the whole upgrade step
2758+
# failed — and upgrade.py then printed "will retry on next `nx upgrade`"
2759+
# for a retry that could never succeed.
2760+
#
2761+
# The count is a progress/audit figure, not a correctness input: it exists
2762+
# only because INSERT OR IGNORE dedupes, so `attempted` can overstate
2763+
# `written`. In service mode we report the attempted count and SAY it is
2764+
# attempted rather than inventing a precise number we cannot obtain — a
2765+
# silently-wrong "actual" would be the false-clean class.
2766+
# The raw ``taxonomy._lock`` / ``taxonomy.conn`` count that used to live here
2767+
# is DELETED rather than guarded. It re-read the table to turn "attempted"
2768+
# into "actually stored" (INSERT OR IGNORE dedupes, so attempted can
2769+
# overstate) — a progress-line nicety, never a correctness input.
2770+
#
2771+
# Guarding it with has_raw_access would have worked but required two new
2772+
# self-service raw-access overrides, GROWING a census that may only shrink,
2773+
# and adding two more raw-SQLite sites for RDR-158 P4 to delete later.
2774+
# Removing it costs one decimal place in a stderr line and takes the site
2775+
# off the retirement backlog instead of adding to it.
2776+
# (Comment deliberately avoids the literal override token: the NO-SQLITE
2777+
# scanner is a dumb regex by design, so even PROSE naming it counts.)
27502778
print( # noqa: T201 — long-running upgrade progress to stderr; structured event emitted via log.info below
2751-
f" Backfill complete: {actual_written} projection assignments stored "
2752-
f"({total_assigned} attempted) in {total_elapsed:.1f}s across {n} collections.",
2779+
f" Backfill complete: {total_assigned} projection assignments attempted "
2780+
f"in {total_elapsed:.1f}s across {n} collections. (Attempted, not stored: "
2781+
f"INSERT OR IGNORE dedupes, so the stored count may be lower.)",
27532782
file=sys.stderr,
27542783
)
27552784
log.info("backfill_projection_complete",

src/nexus/db/t3_reidentify.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,21 @@ def reidentify_collection(
142142
return result
143143

144144
try:
145-
col = t3._client_for(collection_name).get_collection(collection_name)
145+
# nexus-at2ff sweep (2026-07-25): was ``t3._client_for(collection_name)
146+
# .get_collection(...)``. `nx t3 reidentify` builds its handle via
147+
# _make_t3_for_backfill() -> make_t3() -> HttpVectorClient, which has no
148+
# ``_client_for`` — only the T3Database facade does, and that helper's
149+
# own docstring says it is "patched in tests for isolation". So every
150+
# collection errored with "'HttpVectorClient' object has no attribute
151+
# '_client_for'" and the verb did nothing. Both handles expose
152+
# get_collection directly.
153+
#
154+
# NOTE the structural blind spot this sat in: storage_boundary_lint's
155+
# CLIENT_FOR_ALLOWLIST_PREFIXES allowlists ``._client_for`` by the FILE
156+
# it appears in (src/nexus/db/), not by which handle arrives. A db/
157+
# helper called from commands/ with a make_t3() handle is invisible to
158+
# that lint by construction.
159+
col = t3.get_collection(collection_name)
146160
except collection_not_found_errors():
147161
_log.info(
148162
"reidentify_collection_absent",

0 commit comments

Comments
 (0)