| title | RDR-095: Post-Store Hook Framework: Batch Contract | ||||||
|---|---|---|---|---|---|---|---|
| id | RDR-095 | ||||||
| type | Architecture | ||||||
| status | closed | ||||||
| priority | medium | ||||||
| author | Hal Hildebrand | ||||||
| reviewed-by | self | ||||||
| created | 2026-04-25 | ||||||
| accepted_date | 2026-04-25 | ||||||
| closed_date | 2026-04-25 | ||||||
| close_reason | implemented | ||||||
| related_issues | |||||||
| related_tests |
|
||||||
| related |
|
RDR-070 introduced register_post_store_hook in src/nexus/mcp_infra.py
as the canonical extensibility point for per-document post-store
enrichment. The framework currently fires from one site only:
src/nexus/mcp/core.py:887 inside the MCP store_put tool. Every
CLI ingest path (indexer.py, code_indexer.py, prose_indexer.py,
pipeline_stages.py, doc_indexer.py) bypasses the chain and
calls two hardcoded enrichment functions directly:
taxonomy_assign_batch (RDR-070) and chash_dual_write_batch
(RDR-086). Both have identical call-site shape: hardcoded into the
same seven sites across the same five files, fired one immediately
after the other in each. New per-document enrichments (RDR-089
aspect extraction is the immediate pressure) face a forced choice:
register a hook that only covers the MCP path, or copy-paste
fourteen more hardcoded calls. Both options are wrong. This RDR
adds a batch contract to the hook framework and migrates both
hardcoded callers so the framework covers every ingest path
through registered hooks.
fire_post_store_hooks is invoked from exactly one site:
src/nexus/mcp/core.py:887 inside the MCP store_put tool body.
The CLI ingest paths call two hardcoded enrichment functions
directly across seven sites in five files:
| Site | Calls |
|---|---|
src/nexus/indexer.py:807-815 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/code_indexer.py:435-443 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/prose_indexer.py:200-208 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/pipeline_stages.py:408-416 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/doc_indexer.py:373-381 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/doc_indexer.py:499-507 |
chash_dual_write_batch, taxonomy_assign_batch |
src/nexus/doc_indexer.py:902-910 |
chash_dual_write_batch, taxonomy_assign_batch |
None of these sites invoke fire_post_store_hooks. They hardcode
both currently known consumers.
This contradicts RDR-070 RF-070-6 which designed the hook chain as
the per-document post-store mounting point. The
taxonomy_assign_batch calls landed after RDR-070 as a perf
shortcut (see Gap 2). chash_dual_write_batch (RDR-086) followed
the same shape, copying the hardcoding into the same seven sites.
The pattern is now the precedent that any future per-document
enrichment will inherit unless this RDR fixes it.
taxonomy.assign_batch (src/nexus/db/t2/catalog_taxonomy.py:2031)
issues a single ChromaDB Cloud query() call for N documents,
returning N nearest-centroid assignments in one network round-trip.
The single-document hook (taxonomy_assign_hook in
mcp_infra.py:365) issues one query() call per document.
For a 1000-document bulk ingest, the difference is approximately 50ms (one batched query) versus 50 seconds (1000 sequential queries). The 1000x cliff is real, ChromaDB Cloud's network round- trip dominates. Forcing the hardcoded calls to flow through the existing single-document chain would cause an unacceptable bulk- ingest regression.
chash_dual_write_batch (mcp_infra.py:507, RDR-086) has the
same shape: one batched T2 SQL upsert covering N rows is materially
cheaper than N separate single-row upserts (transaction overhead
plus per-row prepared-statement re-binding). The cost asymmetry
is smaller than taxonomy's 1000x ChromaDB cliff but still
meaningful at corpus scale.
The framework therefore needs a batch contract, not a removal of the existing single-document contract. Both shapes are real and both currently-hardcoded enrichments justify batch-shape hooks.
RDR-089 (aspect extraction) is the next consumer. Aspects do not
batch through claude_dispatch (one Haiku call per document by
construction), so single-document hook semantics are correct for
aspects. But there is no clean place for aspects to register today
because the chain misses the dominant CLI ingest path. Without a
fix, RDR-089 is forced to either accept partial coverage or add a
third hardcoded enrichment to all seven CLI sites, joining
taxonomy_assign_batch and chash_dual_write_batch.
Future enrichments (entity extraction, glossary capture, license classification) will face the same choice. Each one paid in duplication is a permanent tax. Fix once, all future enrichments register cleanly.
RDR-070 (taxonomy clustering, 2026-04-12) introduced
register_post_store_hook as the trigger architecture for per-
document topic assignment. RF-070-6 explicitly designed the hook
chain as the canonical extensibility point and noted that batch
indexing was unaffected because batch ingest could trigger
nx taxonomy discover at the end of the pipeline (corpus-level
HDBSCAN, a separate concern).
taxonomy_assign_batch was added later as a per-document fast
path for CLI ingest, alongside the existing assign_hook for MCP
store_put. It was a pragmatic choice when taxonomy was the only
consumer: hardcoding into a handful of ingest files cost the same
as plumbing the hook chain, and the ChromaDB-batched query
genuinely won. RDR-086 followed the same template for chash
dual-write, copying the hardcoding pattern into the same files.
The set is now seven sites in five files for each function (so
fourteen call sites total) and the framework's single-shape
limitation has become visible because RDR-089 needs to add a
third consumer.
The investigation that led to this RDR also looked at three other
post-store actions: _catalog_store_hook / _catalog_pdf_hook /
indexer.py's ad-hoc registration (three catalog-registration
mechanisms) and _catalog_auto_link (the agent-driven link
creator). None of these belong in this RDR. The catalog
mechanisms have genuinely different per-domain semantics (see
Decision Rationale). The auto-linker reads T1 scratch entries
seeded by agents before MCP store_put; CLI bulk ingest has no
equivalent pre-declaration semantics and uses entirely separate
post-hoc linkers (generate_citation_links, generate_code_rdr_links,
generate_rdr_filepath_links in link_generator.py). MCP-only
auto-linking is intentional path-shape coupling, not coverage gap.
src/nexus/mcp_infra.py:296.register_post_store_hookandfire_post_store_hooks. The current single-document hook chain. Failure capture via_record_hook_failurewrites to T2hook_failures(RDR-070, GH #251).src/nexus/mcp_infra.py:365.taxonomy_assign_hook. Existing single-document hook, currently registered.src/nexus/mcp_infra.py:453.taxonomy_assign_batch. Hardcoded fast path for taxonomy assignment, called from seven CLI ingest sites. RDR-095 migrates this into the chain as a registered batch hook.src/nexus/mcp_infra.py:507.chash_dual_write_batch(RDR-086). Hardcoded fast path for content-hash dual-write, called from the same seven sites in the same five files, fired immediately beforetaxonomy_assign_batchat each site. RDR-095 migrates this alongside taxonomy as a second registered batch hook.src/nexus/db/t2/catalog_taxonomy.py:2031.assign_batch. Single ChromaDB query for N documents, plus per-documentassign_topicSQL upsert. The ChromaDB call is the load-bearing optimization, the SQL writes are not.src/nexus/db/t2/chash_index.py. Chash dual-write target. Single T2 SQL upsert covering N rows. Cheaper than N single-row upserts for transaction overhead reasons but not as load-bearing as the taxonomy ChromaDB cliff.src/nexus/mcp/core.py:887. The only call site offire_post_store_hooks.src/nexus/indexer.py:807-815,src/nexus/code_indexer.py:435-443,src/nexus/prose_indexer.py:200-208,src/nexus/pipeline_stages.py:408-416,src/nexus/doc_indexer.py:373-381,src/nexus/doc_indexer.py:499-507,src/nexus/doc_indexer.py:902-910. The seven CLI ingest sites where both hardcoded enrichments fire (chash first, then taxonomy at each site).
Ran the call-site survey on 2026-04-25 (RDR-089 verify pass):
grep -rn "fire_post_store_hooks\|register_post_store_hook" src/nexus/
# Definitions: mcp_infra.py:296, :301
# Call sites: mcp/core.py:887 (single)
grep -rn "taxonomy_assign_batch\|chash_dual_write_batch" src/nexus/
# Definitions: mcp_infra.py:453 (taxonomy), mcp_infra.py:507 (chash)
# Call sites: indexer.py:807-815, code_indexer.py:435-443,
# prose_indexer.py:200-208, pipeline_stages.py:408-416,
# doc_indexer.py:373-381, doc_indexer.py:499-507,
# doc_indexer.py:902-910
# Both functions called at every site (chash first, taxonomy second).
A third sweep checked the rest of the post-store landscape:
_catalog_store_hook, _catalog_pdf_hook, indexer.py:250's
ad-hoc registration, _catalog_auto_link. None match the
taxonomy_assign_batch shape; see Decision Rationale for why
they belong outside RDR-095's scope.
Inspected taxonomy.assign_batch (catalog_taxonomy.py:2031) to
confirm the ChromaDB-Cloud round-trip count: one query() call
per assign_batch invocation, regardless of N. The per-document
assign_topic SQL writes are independent of the batch shape and
fire either way.
Inspected chash_dual_write_batch (mcp_infra.py:507): single
batched T2 SQL upsert into chash_index. The batch optimization
here is transaction overhead and prepared-statement re-binding,
not a network cliff. Smaller perf win than taxonomy but consistent
in shape: one batched call cheaper than N single calls. Same
batch contract serves both.
| Dependency | Source Searched? | Key Findings |
|---|---|---|
fire_post_store_hooks |
Yes | Catches per-hook exceptions, persists to T2 hook_failures, never blocks the calling store_put. Signature (doc_id, collection, content). |
taxonomy.assign_batch |
Yes | One ChromaDB query() for N docs, then N SQL writes via assign_topic. The ChromaDB call is the perf-critical part. |
register_post_store_hook |
Yes | Append-only registration into a module-level list. Module-import-time hook registration in mcp/core.py:367 is the only current registration site. |
- Verified (
mcp_infra.py:296-326): the existing hook chain has a clean failure-isolation pattern (per-hook try/except, T2 persistence, structured log). Reuse for the batch contract. - Verified (
catalog_taxonomy.py:2078-2086): the ChromaDB batched query is the entire perf optimization. The SQL writes are linear either way. - Verified (no test mocks of
taxonomy_assign_batch): a single docstring reference intests/test_chash_index_store.py:454. Migration does not break test fixtures. - Verified (
mcp/core.py:887): the single fire site is the only place the chain runs today. Any new fire site is additive.
- ChromaDB-batched query is the load-bearing perf optimization
for
assign_batch, not the SQL writes. Status: Verified by source search.assign_batchissues onequery()call (single network round-trip to ChromaDB Cloud), then Ntaxonomy.assign_topicSQL upserts in a tight loop. The SQL upserts are linear either way. Method: Source Search. - Per-document hook overhead in batch fire path is negligible next to extractor work. Status: Unverified. Hook framework overhead is a Python function call plus try/except (microseconds); hooks themselves do real work (Haiku call ~1-3s for aspects, ANN lookup for taxonomy). Method: confirm by smoke-test against a 100-document fixture corpus once Phase 2 lands.
- Test fixtures that index documents under
nx index repodo not break when batch hooks fire on previously-untracked paths. Status: Partially verified by source search (095-research-2 confirmed only one docstring reference totaxonomy_assign_batchintests/test_chash_index_store.py:454, not a live mock). Final verification requires full suite run post-migration. Method: confirm via full unit test run after Phase 2.
Four pieces, in order:
- Add the batch contract. Extend
mcp_infra.pywithregister_post_store_batch_hook(fn)andfire_post_store_batch_hooks(doc_ids, collection, contents, embeddings, metadatas). Same per-hook failure-capture pattern as the existing single-document chain. - Migrate taxonomy. Convert
taxonomy_assign_batchfrom a directly-called function into a registered batch hook (taxonomy_assign_batch_hook). Register at MCP server startup alongside the existingtaxonomy_assign_hook. - Migrate chash dual-write. Convert
chash_dual_write_batchfrom a directly-called function into a registered batch hook (chash_dual_write_batch_hook). Register at MCP server startup.chash_dual_write_batch_hookreadsmetadatasfrom the fire payload;taxonomy_assign_batch_hookreadsembeddings. Both ignore parameters they don't need. The per-hook failure capture ensures one consumer's failure doesn't affect the other. - Replace the fourteen hardcoded sites. In
indexer.py:807-815,code_indexer.py:435-443,prose_indexer.py:200-208,pipeline_stages.py:408-416, anddoc_indexer.py(three sites), delete BOTH the directchash_dual_write_batchcall and the directtaxonomy_assign_batchcall (currently fired in pairs) and replace each pair with a singlefire_post_store_batch_hooks(doc_ids, collection, contents, embeddings, metadatas)invocation. Both registered hooks fire in registration order.
After this RDR lands, future per-document enrichments register
once via register_post_store_hook (single-shape) or
register_post_store_batch_hook (batch-shape) and are
automatically called from every ingest path.
New signatures (mcp_infra.py):
_post_store_batch_hooks: list = []
def register_post_store_batch_hook(fn) -> None:
"""Register a callable(doc_ids, collection, contents, embeddings,
metadatas) to fire after batch CLI ingest."""
_post_store_batch_hooks.append(fn)
def fire_post_store_batch_hooks(
doc_ids: list[str],
collection: str,
contents: list[str],
embeddings: list[list[float]] | None = None,
metadatas: list[dict] | None = None,
) -> None:
"""Invoke all registered batch hooks. Per-hook failures are
captured and persisted to T2 hook_failures, never raised."""
import structlog
log = structlog.get_logger()
for hook in _post_store_batch_hooks:
try:
hook(doc_ids, collection, contents, embeddings, metadatas)
except Exception as exc:
hook_name = getattr(hook, "__name__", "?")
log.warning(
"post_store_batch_hook_failed",
hook=hook_name,
exc_info=True,
)
_record_batch_hook_failure(
doc_ids=doc_ids,
collection=collection,
hook_name=hook_name,
error=str(exc),
)embeddings and metadatas are both optional. Different consumers
need different inputs:
taxonomy_assign_batch_hookreadsembeddings(ignoresmetadatas).chash_dual_write_batch_hookreadsmetadatas(ignoresembeddings).- Future text-only consumers (aspect extraction is single-shape
and registers via the single-doc hook chain, but text-only batch
consumers could exist) need only
contents.
When the caller has any of these already (all seven CLI ingest sites have all three: doc_ids + contents + embeddings + metadatas), passing preserves the existing perf optimisation. The hook reads what it needs and ignores the rest.
contents is required because the canonical use case is per-
document text-keyed enrichment. Callers that have content in a
different shape (chunks, summaries) join into a single string at
the call site.
Failure persistence: _record_batch_hook_failure mirrors the
existing _record_hook_failure but persists the full doc_id list
for batch failures. The existing hook_failures.doc_id column is
TEXT and stores a scalar id today (_record_hook_failure writes
a single string; nx taxonomy status and any future readers parse
it as scalar). Repurposing the column to hold either a scalar or
a JSON array would silently corrupt those readers on first batch-
hook failure. RDR-095 therefore adds a small additive migration
via the RDR-076 registry:
- New column
hook_failures.batch_doc_ids(TEXT, nullable). When the failure originates in a batch hook, store the JSON-encoded doc_id list here; leavedoc_idset to a representative single id (the first in the batch) so existing scalar readers continue to display something meaningful. - New column
hook_failures.is_batch(INTEGER, default 0). Boolean flag for fast filtering. Readers that care about batch-vs-single distinction (a futurenx doctorsurface, ornx taxonomy status) check this flag before parsingbatch_doc_ids. nx taxonomy statusupdated in Phase 3 to display batch-shaped failures with the doc_id list rendered, rather than treating them as scalar.
The migration entry lands in src/nexus/db/migrations.py alongside
the framework primitives in Phase 1; the reader update is deferred
to Phase 3 alongside the drift guard.
Migrated batch hooks (mcp_infra.py, renamed):
def taxonomy_assign_batch_hook(
doc_ids: list[str],
collection: str,
contents: list[str],
embeddings: list[list[float]] | None,
metadatas: list[dict] | None,
) -> None:
"""Registered batch hook. Wraps the existing
taxonomy.assign_batch perf path. Reads embeddings; ignores
metadatas."""
if not doc_ids or not embeddings:
return
# ... existing exclusion + assign_batch logic ...
def chash_dual_write_batch_hook(
doc_ids: list[str],
collection: str,
contents: list[str],
embeddings: list[list[float]] | None,
metadatas: list[dict] | None,
) -> None:
"""Registered batch hook. Wraps the existing chash dual-write
perf path. Reads metadatas; ignores embeddings."""
if not doc_ids or not metadatas:
return
# ... existing chash_dual_write logic ...Each body is the existing function body verbatim, with the return
type narrowed to None (return values were never read by callers,
confirmed by grep) and the unused parameters present-but-ignored.
Registration (in mcp/core.py near the existing
register_post_store_hook call):
from nexus.mcp_infra import (
register_post_store_hook,
register_post_store_batch_hook,
taxonomy_assign_hook,
taxonomy_assign_batch_hook,
chash_dual_write_batch_hook,
)
register_post_store_hook(taxonomy_assign_hook)
register_post_store_batch_hook(chash_dual_write_batch_hook)
register_post_store_batch_hook(taxonomy_assign_batch_hook)Registration order matters: chash_dual_write_batch_hook runs
before taxonomy_assign_batch_hook because that mirrors the
current call-site ordering (chash precedes taxonomy at every site)
and preserves the dual-write-first invariant for any reader that
expects chash rows present before topic assignment runs.
CLI ingest call-site replacement (one example, the rest are identical in shape):
Before (indexer.py:807-815):
from nexus.mcp_infra import chash_dual_write_batch
chash_dual_write_batch(ids, collection_name, metadatas)
# ... a few lines of intervening logic ...
from nexus.mcp_infra import taxonomy_assign_batch
taxonomy_assign_batch(ids, collection_name, embeddings)After:
from nexus.mcp_infra import fire_post_store_batch_hooks
fire_post_store_batch_hooks(
ids,
collection_name,
<document_texts>, # local variable name varies by site
embeddings,
metadatas,
)The <document_texts> placeholder above is the per-document text
already in scope at each call site. The actual local variable
name varies and must be looked up per file:
indexer.py:807-815:documentscode_indexer.py:435-443:documentsprose_indexer.py:200-208:documentspipeline_stages.py:408-416:documentsdoc_indexer.py:373-381:documentsdoc_indexer.py:499-507:batch_docsdoc_indexer.py:902-910:documents
(Confirmed by source survey 2026-04-25; the prerequisite checklist re-verifies these names at implementation time.) Each pair of hardcoded calls collapses into one hook-chain fire.
| Proposed Component | Existing Module | Decision |
|---|---|---|
| Batch hook list, register, fire | mcp_infra.py:_post_store_hooks, register_post_store_hook, fire_post_store_hooks |
Mirror: parallel single-shape contract, same conventions, same failure isolation. |
_record_batch_hook_failure |
mcp_infra.py:_record_hook_failure (RDR-070) |
Mirror: same T2 hook_failures table, same error truncation. JSON-serialize the doc_id list into the existing doc_id column. |
| Migrated taxonomy hook body | mcp_infra.py:453 (taxonomy_assign_batch) |
Move + rename: function body unchanged, called via the chain instead of directly. |
| Migrated chash hook body | mcp_infra.py:507 (chash_dual_write_batch, RDR-086) |
Move + rename: function body unchanged. Same migration shape as taxonomy; same registration call. |
| Caller-side replacements | indexer.py:807-815, code_indexer.py:435-443, prose_indexer.py:200-208, pipeline_stages.py:408-416, doc_indexer.py:373-381, doc_indexer.py:499-507, doc_indexer.py:902-910 |
Collapse: each pair of hardcoded calls (chash + taxonomy) collapses into one fire_post_store_batch_hooks invocation. Seven sites across five files; ~5 lines removed and ~3 added per site. |
| Catalog-registration mechanisms | _catalog_store_hook (commands/store.py:116), _catalog_pdf_hook (pipeline_stages.py:458), indexer.py:250 ad-hoc |
Out of scope, intentionally: each captures different per-domain metadata (knowledge-curator + doc_id; corpus-curator + file_path + author + year + chunk_count; repo-owner + rel_path + source_mtime + file_hash). Consolidating would lose information or branch internally based on origin. Three legitimate per-domain registrations, not three copies of the same hook. |
_catalog_auto_link |
mcp_infra.py:catalog_auto_link |
Out of scope, intentionally: reads T1 scratch entries tagged link-context, agent-seeded before MCP store_put. CLI bulk ingest has no per-file pre-declaration semantics; uses entirely separate post-hoc linkers in link_generator.py. MCP-only is path-shape coupled. |
| Single-document chain | mcp_infra.py:296, mcp/core.py:887 |
Untouched: the MCP store_put path keeps firing single-shape hooks (taxonomy_assign_hook, future single-shape consumers like RDR-089 aspect_assign_hook). |
The framework has two real workload shapes (single-document MCP,
batch CLI) and one pretend-extensibility mechanism (single-shape
hooks, hardcoded batch calls). Adding a batch contract makes both
shapes first-class. The cost is small: roughly 30 lines of new
framework code (register + fire + failure capture), two function
renames (taxonomy_assign_batch and chash_dual_write_batch to
their _hook variants), and the seven call-site replacements.
The alternative is to keep the duplication: register single-shape
hooks for the MCP path, hardcode batch calls into the five CLI
files for any consumer that benefits from batching. This is a
permanent tax on every future per-document enrichment, paid in
multi-file edits and easy-to-miss coverage. The set has already
grown from four sites at first sighting to seven once the survey
broadened, then to fourteen call sites once chash_dual_write_batch
was counted. The pattern is the precedent: every new per-document
batch enrichment will copy itself into the same five files unless
the framework absorbs the work. RDR-089's verify pass caught the
tax explicitly. There will be more.
Why catalog-registration and auto-linker are NOT in scope. The investigation that broadened the call-site count from seven to fourteen also examined three other post-store mechanisms. None match the same shape:
- The three catalog-registration paths (
_catalog_store_hook,_catalog_pdf_hook,indexer.py:250's ad-hoc registration) capture different per-domain metadata: knowledge-curator owner + doc_id for ad-hoc store; corpus-curator owner + file_path + author + year + chunk_count for PDFs; repo-owner + rel_path + source_mtime + file_hash for repo files. Consolidating would either lose information or branch internally based on origin. They are three legitimate per-domain registrations, not three copies of the same hook. _catalog_auto_linkreads T1 scratch entries taggedlink-contextthat agents seed before callingstore_put. CLI bulk ingest has no equivalent pre-declaration semantics; it uses entirely separate post-hoc linkers (generate_citation_links,generate_code_rdr_links,generate_rdr_filepath_linksinlink_generator.py). MCP-only auto-linking is intentional path-shape coupling.
These were genuine deferrals (not scope-reduction debt) once the
shape comparison showed they are different mechanisms doing
different work. The two functions this RDR migrates
(taxonomy_assign_batch and chash_dual_write_batch) are the
real twins: identical call-site shape, identical seven sites,
identical batch-perf-optimization motivation.
The single-document chain stays. The MCP store_put tool still
fires it; future single-shape consumers (small, cheap, per-doc
hooks like RDR-089 aspect extraction) register through it. We are
not removing a contract, we are adding the missing one.
The narrow batch-perf optimizations are preserved because each
migrated hook wraps its existing function body verbatim:
taxonomy_assign_batch_hook keeps the one-query()-per-batch
ChromaDB call, chash_dual_write_batch_hook keeps the
single-batched T2 SQL upsert. No regression on bulk ingest for
either consumer.
Closure review surfaced an asymmetry the RDR shipped intentionally:
the single-document chain fired only from MCP store_put, the batch
chain fired only from CLI ingest. Future single-shape consumers
(RDR-089 aspect extraction with one Haiku per doc) had no clean way
to fire from CLI without re-introducing the hardcoded-call debt
RDR-095 dismantled. The follow-up commit makes both chains fire from
every storage event:
taxonomy_assign_hookwas deleted as redundant withtaxonomy_assign_batch_hook. The batch hook now acceptsembeddings=Nonefrom MCPstore_putand fetches embeddings via a new_fetch_or_embedhelper (one ChromaDB get plus local-MiniLM fallback), preserving the legacy single-doc shim's round-trip cost.- MCP
store_putfires both chains: single-doc once, batch with a 1-element list. - All seven CLI ingest sites fire the batch chain with the full
payload PLUS a per-doc
fire_post_store_hooksloop. Loop overhead is microseconds when the single-doc chain is empty (current state) and meaningful only once a single-doc consumer registers. tests/test_hook_drift_guard.pyaddstest_every_cli_ingest_site_fires_both_chainswhich usesast.walkto verify every CLI indexer module has matching counts of both fire calls. Symmetric coverage is now CI-enforced.
The symmetric-fire commit closes the unattended-mess concern raised during closure review: any consumer registers in exactly one shape based on whether its work benefits from batched dependency calls, and the framework guarantees coverage from every storage path without per-path duplication.
Description: Plumb fire_post_store_hooks into the seven CLI
ingest sites, delete taxonomy_assign_batch, accept the perf cost.
Pros: Smallest framework surface, single shape only.
Cons: 1000x bulk-ingest regression on taxonomy assignment for medium-and-large corpora. The ChromaDB Cloud round-trip is the load-bearing cost; replacing one batched query with N sequential queries turns a 50ms operation into ~50s for 1000 documents. Not acceptable.
Reason for rejection: Eats a real perf optimization for no
gain. The two-shape reality is intrinsic, not avoidable. Note: the
post-acceptance symmetric-fire follow-up (see Decision Rationale)
plumbs fire_post_store_hooks into CLI sites in a per-doc loop,
but only after collapsing taxonomy into the batch chain so the
loop iterates an empty single-doc chain by default. The perf cliff
this alternative described is therefore avoided: only consumers
that explicitly register single-shape pay the per-doc cost, and
consumers that register batch keep the one-batched-query cost.
Description: Change fire_post_store_hooks signature to
(doc_id, collection, content, **ctx) where ctx carries optional
fields like embedding. Hooks consume what they need. Plumb the
chain into CLI paths, pass embeddings via ctx.
Pros: One contract, hooks pick what they consume, no batch machinery.
Cons: Still N ChromaDB round-trips for taxonomy. The embedding-reuse fixes the local re-embed cost (~1ms per document becomes 0) but the dominant cost is the ChromaDB Cloud query call, which happens regardless. Pre-fetching the embedding does not collapse N queries into one.
Reason for rejection: Looks like a fix, is not. The shape mismatch is at the ChromaDB API boundary, not at the embedding- fetch boundary.
Description: RDR-089 plumbs fire_post_store_hooks into the
CLI sites for non-taxonomy enrichment only. Taxonomy keeps its
hardcoded path. The two paths become a documented perf split.
Pros: No framework changes, RDR-089 lands sooner.
Cons: Documents the duplication as a feature. Every future enrichment author must understand the split: which workloads batch (and need their own hardcoded fast path), which do not (and register through the chain). The decision is fragile, mostly unwritten, and grows in cost with each new consumer.
Reason for rejection: Defers cost without reducing it. Acceptable as a tactical fallback if RDR-095 cannot land in time for RDR-089, not as a long-term shape.
- Make taxonomy register both single and batch under one hook object: conflates two contracts at the registration call site; callers either fire single or fire batch, never both per document.
- Replace taxonomy's CLI fast path with a discover-only rebuild: corpus-level rebuild is not a substitute for per- document assignment; freshness lag would be hours instead of milliseconds.
- Framework gains roughly 30 lines:
register_post_store_batch_hook,fire_post_store_batch_hooks,_record_batch_hook_failure. taxonomy_assign_batchbecomestaxonomy_assign_batch_hook, body unchanged, called via the chain.chash_dual_write_batchbecomeschash_dual_write_batch_hook, body unchanged, called via the chain.- Five CLI ingest files (seven sites) each lose two hardcoded
function calls and gain one
fire_post_store_batch_hookscall. Net per site: roughly 5 lines removed, 3 lines added. - T2
hook_failuresrows from batch hooks store the doc_id list as a JSON array in the existingdoc_idcolumn. Callers that parse the column must tolerate either shape. - New per-document enrichments (RDR-089 aspects, future RDRs) get a clean registration path that covers every ingest.
- The three catalog-registration mechanisms and the auto-linker remain as-is. RDR-095 does not move them; their per-domain semantics make them legitimate non-twins of the batch-hook pattern.
-
Risk: A registered batch hook raises mid-way through a bulk-ingest fire, partial work commits, partial does not. Mitigation: per-hook failure capture wraps the entire batch invocation; failures are atomic at hook granularity. The registered batch hook owns its own atomicity (current
taxonomy.assign_batchalready handles partial failures by returning a smallerassignedcount). Consumer hooks document their atomicity model. -
Risk: Hook-registration ordering matters when one hook reads state another wrote. Mitigation: registration order is deterministic (append to list), MCP server startup imports are deterministic. The current call-site ordering (chash before taxonomy) is preserved by registering
chash_dual_write_batch_hookfirst. If a richer cross-hook dependency emerges later, the framework can grow a priority argument; today it does not need one. -
Risk: The seven CLI call-site replacements drift apart over time as authors add similar code without using the chain. Mitigation: a static check (grep-based, in
tests/orscripts/checks/) asserts that no source file outsidemcp_infra.pycallstaxonomy_assign_batch_hookorchash_dual_write_batch_hookdirectly. Wire intonx doctorif desirable. -
Risk: A future contributor adding a new per-document batch enrichment copies the hardcoded pattern (mirroring what
chash_dual_write_batchdid totaxonomy_assign_batch) instead of registering a hook. Mitigation: documentation in CLAUDE.md and the post-store hook docstring explicitly directs new consumers to register a hook; the drift guard catches direct-call regressions; the three already-migrated examples (taxonomy, chash, future RDR-089) serve as worked precedents.
- Visible: a registered batch hook raises, structured log
entry plus T2
hook_failuresrow (with the full doc_id list inbatch_doc_ids), ingest completes successfully. - Visible: callers pass empty
doc_ids/embeddings, fire- function returns early, no hooks called. - Partial-commit, partial-diagnostic (new failure-isolation
shape): a batch hook makes multiple internal dependency calls
(e.g.
taxonomy_assign_batch_hookissues two ChromaDB queries: same-collection then cross-collection projection). If the second call raises after the first committed, up to N same-collection assignments land but zero cross-collection projections. Thehook_failuresrow captures the doc_id list and the exception, but does not capture which sub-step succeeded. This is a legitimate weakening relative to the per-document granularity of the single-doc chain (which fires once perstore_putand so cannot have partial-commit at the framework level). Mitigation options:- Document the contract: registered batch hooks are atomic at the hook-invocation level, not the per-document level within the hook. Hook authors are responsible for their own sub-step atomicity.
- Sub-step recovery is hook-internal:
taxonomy.assign_batchalready returns a smallerassignedcount on partial query failures within a single ChromaDB call. Authors of new batch hooks should follow this pattern (catch transient errors at the dependency boundary, return partial-success counts). - The framework does not introduce per-sub-step failure capture
in this RDR; doing so would couple the framework to specific
consumer shapes. Future RDR can add a
record_partial_progresshelper if a consumer needs it.
- Silent: a registered batch hook silently does the wrong thing (mis-assigns to wrong topic, writes to wrong table). Same failure mode as today; the chain does not introduce new silent failures.
- Confirm test fixtures:
tests/test_chash_index_store.py:454is a docstring reference, not a mock. Search broader test suite for any direct mocks oftaxonomy_assign_batchorchash_dual_write_batch; migrate test fixtures to mock the hooks instead if any are found. - Identify per-document content sources and metadata sources at
each of the seven CLI call sites (local variable names confirmed
in the Technical Design section:
documentsat six sites,batch_docsatdoc_indexer.py:499-507). Re-verify at implementation time in case unrelated edits have shifted names. - Drift guard wired before migration lands, not after. The
Phase 3 static check that asserts no production source file
outside
mcp_infra.pycallstaxonomy_assign_batch_hookorchash_dual_write_batch_hookdirectly belongs in the test suite the moment the hooks are renamed (Phase 2 Step 1), so any call-site replacement that drifts will fail CI. Promoting from Phase 3 prevents the very debt-accretion this RDR exists to reverse.
- One probe batch hook registered in a unit test, fires on a
10-document fixture indexed via
nx index repo, asserts the hook saw all 10 documents in the expected collection. - Existing taxonomy integration test passes after migration (no behavior regression on bulk ingest).
- Manual smoke:
nx index pdfon a small corpus, verify topic assignments still land in T2topic_assignmentsexactly as before.
- Add
_post_store_batch_hookslist. - Add
register_post_store_batch_hook(fn). - Add
fire_post_store_batch_hooks(doc_ids, collection, contents, embeddings, metadatas)with both optional payloads. - Add
_record_batch_hook_failure(doc_ids, collection, hook_name, error).
tests/test_mcp_infra.py: register a probe hook, fire, assert
arguments. Failure-isolation test: probe hook raises, fire still
returns, second probe hook still fires, T2 hook_failures row
written.
taxonomy_assign_batch(doc_ids, collection, embeddings)becomestaxonomy_assign_batch_hook(doc_ids, collection, contents, embeddings, metadatas). Body unchanged; readsembeddings, ignorescontentsandmetadatas.chash_dual_write_batch(doc_ids, collection, metadatas)becomeschash_dual_write_batch_hook(doc_ids, collection, contents, embeddings, metadatas). Body unchanged; readsmetadatas, ignorescontentsandembeddings.- Register at MCP server startup, chash first then taxonomy:
Order preserves the existing chash-before-taxonomy invariant from the call sites.
register_post_store_batch_hook(chash_dual_write_batch_hook) register_post_store_batch_hook(taxonomy_assign_batch_hook)
In each of indexer.py:807-815, code_indexer.py:435-443,
prose_indexer.py:200-208, pipeline_stages.py:408-416,
doc_indexer.py:373-381, doc_indexer.py:499-507,
doc_indexer.py:902-910, replace BOTH the direct
chash_dual_write_batch call AND the direct
taxonomy_assign_batch call (currently fired in pairs) with a
single
fire_post_store_batch_hooks(doc_ids, collection, contents, embeddings, metadatas) invocation.
Each call site already has contents, embeddings, and
metadatas in scope (the same sources currently feeding the
paired hardcoded calls); confirm during the prerequisite survey.
The three doc_indexer.py sites correspond to three distinct
ingest entry points within that file (initial bulk write,
batch-loop within bulk indexing, and per-document path); each
gets its own caller-side replacement.
The hook_failures reader (today displays the scalar doc_id
column) gains a branch for is_batch=1 rows that parses
batch_doc_ids and renders the doc_id list. Without this, batch
failures show up as opaque rows whose doc_id field is the first
id in the batch with no indication that more docs were affected.
Brief note on the dual-shape contract: single-document hooks for ad-hoc/MCP enrichment, batch hooks for bulk-ingest enrichment with batched dependency calls. Cross-reference RDR-070 and RDR-095. Also document the explicit out-of-scope decisions: catalog registration mechanisms are per-domain by design; the auto-linker is MCP-only by design.
| Resource | List | Info | Verify |
|---|---|---|---|
| Registered hooks | nx doctor --check=hooks |
enumerate registered hook names | confirm taxonomy_assign_batch_hook, chash_dual_write_batch_hook, and future hooks are present |
| Hook failures | T2 hook_failures table via nx memory list-style probe |
full row | spot-check error column for parseable JSON when batch-shaped |
None.
- Scenario: register a no-op probe batch hook, run
fire_post_store_batch_hooks([], "x", [], None, None), verify return is None and no hooks fired (early return on empty doc_ids). - Scenario: register two batch hooks where the first is a
synthetic raising probe (the real
taxonomy_assign_batch_hookbody wraps everything in its own try/except and returns 0 on any exception, so it cannot exercisefire_post_store_batch_hooks's failure capture). Verify the second probe still fires after the first raises, and that ahook_failuresrow is written withis_batch=1, the full doc_id list inbatch_doc_ids, and the exception text inerror. - Scenario: a batch hook commits its first sub-step (e.g. a
same-collection ChromaDB query succeeds) then raises in its
second sub-step (cross-collection projection). Verify the
partial-commit failure mode documented in Failure Modes:
hook_failuresrow captures the doc_id list and exception, the same-collection assignments persisted, and structured logs identify the sub-step granularity. - Scenario: run the full unit suite with both migrated batch
hooks (
taxonomy_assign_batch_hook,chash_dual_write_batch_hook) registered, verify behavioral parity with pre-migration baseline. - Scenario:
nx index repo .against a fixture, assert that topic_assignments rows AND chash_index rows both match the pre-migration count. - Scenario: registration order: register chash hook first, taxonomy second; assert chash row exists before taxonomy assignment runs (probe via inserted hook between them).
- Scenario: drift guard regex assertion fails when a new file
imports
taxonomy_assign_batch_hookorchash_dual_write_batch_hookoutsidemcp_infra.py. The assertion is wired in Phase 2 (alongside the renames) so any call-site replacement that drifts fails CI immediately. - Scenario:
hook_failuresschema migration. Afternx upgraderuns the additive migration, verify (a) existing scalar-doc_idrows are unchanged, (b) newbatch_doc_idsandis_batchcolumns exist with appropriate defaults, (c) writing a batch-shape failure populates the new columns and the scalardoc_idfield carries a representative id, (d)nx taxonomy statusrenders both shapes correctly.
Unit tests for the new framework primitives cover the register/fire/failure-isolation triad. Existing taxonomy integration tests cover behavioral parity (no regression). A small manual smoke covers the cross-cutting wiring (full ingest pipeline fires hooks; topic assignments land).
Per-batch fire overhead: a single Python list iteration over registered hooks (microseconds). The hook bodies do the real work. Bulk-ingest taxonomy assignment and chash dual-write are both unchanged from pre-migration because each migrated hook wraps the existing batched implementation verbatim. The two hooks fire sequentially per batch, in registration order; total wall-clock time per batch is the sum of the existing two hardcoded calls (no change from today).
To be completed during /conexus:rdr-gate.
src/nexus/mcp_infra.py:296. Current single-document hook frameworksrc/nexus/mcp_infra.py:365.taxonomy_assign_hooksrc/nexus/mcp_infra.py:453.taxonomy_assign_batch(one of the two hardcoded fast paths being migrated)src/nexus/mcp_infra.py:507.chash_dual_write_batch(RDR-086; the second hardcoded fast path being migrated alongside taxonomy)src/nexus/db/t2/catalog_taxonomy.py:2031.assign_batch, the perf-critical ChromaDB-batched querysrc/nexus/db/t2/chash_index.py. Chash dual-write targetsrc/nexus/mcp/core.py:887. Current single fire sitesrc/nexus/indexer.py:807-815,src/nexus/code_indexer.py:435-443,src/nexus/prose_indexer.py:200-208,src/nexus/pipeline_stages.py:408-416,src/nexus/doc_indexer.py:373-381,src/nexus/doc_indexer.py:499-507,src/nexus/doc_indexer.py:902-910. The seven CLI ingest sites where each pair of hardcoded calls is being collapsed into onefire_post_store_batch_hooksinvocationsrc/nexus/commands/store.py:116,src/nexus/pipeline_stages.py:458,src/nexus/indexer.py:250. The three catalog-registration mechanisms that were investigated and explicitly excluded from scope (per-domain semantics; not twins of the batch-hook pattern)src/nexus/catalog/auto_linker.py,src/nexus/mcp_infra.py:catalog_auto_link. The auto-linker, investigated and explicitly excluded from scope (T1-link-context driven; agent-only by design)- RDR-060: rename detection that motivates
file_hashcapture in the indexer.py catalog registration - RDR-070: taxonomy infrastructure that introduced the single-document hook chain (RF-070-6 designed it as the canonical extensibility point)
- RDR-076: T2 migration framework (T2
hook_failurestable) - RDR-086: chash dual-write (the second hardcoded enrichment, now migrated alongside taxonomy)
- RDR-089: aspect extraction (the consumer that surfaced the framework gap)