When in doubt, check
src/nexus/-- the code is the ground truth.
Four layers: queries come in at the top, get decomposed into plans, executed as a DAG of operators, backed by a catalog-aware knowledge graph. Modeled on the AgenticScholar four-layer reference architecture.
Detailed description of the diagram
The diagram shows four horizontal colored bands stacked vertically, each labeled in its upper-left corner and representing one layer of the Nexus architecture.
The top band (blue, "Application Layer") contains three side-by-side white boxes representing query categories that enter the system: Retrieval Queries (nx search, search MCP, nx memory); Extraction and Synthesis Queries (query MCP, operator_extract, summarize, compare); and Knowledge Discovery and Generation (nx_answer, operator_generate, /conexus:analyze).
The second band (peach, "LLM-Centric Hybrid Planning Layer") is the tallest. On its left edge, a small stack-of-documents icon labeled "Scholarly Queries" feeds horizontally into a Query Decomposer box (/conexus:query, /conexus:plan-first). A "Task" arrow branches upward and rightward into two parallel dashed-border subgroups: "Predefined Plan Selection" (containing plan_match with dimension and semantic rerank, an LLM-based rerank step, and a small PlanLibrary cylinder) and "Dynamic Plan Generator" (three stacked stages: High-level planning, Low-level operator instantiation, and Validation and self-correction). A horizontal dashed "miss" arrow connects Selection to Generator as a fallback.
Three arrows cross downward from the Planning band into the third band: a dashed "Scope" arrow directly below the Query Decomposer, a dashed "matched" arrow below the Predefined Plan Selection, and a solid "Execution Plan" arrow below the Dynamic Plan Generator on the right.
The third band (green, "Unified Execution Layer") contains, left to right: a cluster of four colored hexagons connected by lines representing the Execution Plan DAG; an Execution Engine subgroup containing a plan_run panel (with a miniature DAG glyph) and a Result Cache cylinder labeled T1, connected by a bidirectional arrow; and a Defined Operator Set box divided into three labeled columns — RETRIEVAL (Search, Query, Traverse, FindNode, Filter, GroupBy), SYNTHESIS (Extract, Summarize, Compare, Rank, Generate, Aggregate), and STATE (memory_*, store_*, plan_*, scratch_*, catalog_link, operator_*).
The fourth band (purple, "Knowledge Representation Layer") flows left to right: a stack-of-documents icon labeled "Source Documents" feeds an Inner-document Content Extractor (classifier, chunker via tree-sitter across 31 languages, code_indexer, prose_indexer, pdf_extractor routing Docling → MinerU → PyMuPDF, bib_enricher). An arrow labeled "Scholarly Document Knowledge" continues into Problem/Method Taxonomy Construction (CatalogTaxonomy, BERTopic plus HDBSCAN). Below Taxonomy, a Progressive Update box (auto_linker, taxonomy_assign_hook, link_generator) connects bidirectionally upward and receives a dashed "new documents" arrow from Source Documents. A "construct" arrow leads right from Taxonomy to the Nexus Knowledge Graph — rendered as a node-link cluster of orange and white circles — representing the three-tier store (T1 session scratch, T2 Postgres via the native nexus-service, T3 Postgres 17 + pgvector behind the same nexus-service) with tumbler addresses and typed links (cites, implements, supersedes, relates).
Source: architecture-diagram.svg — edit the SVG directly, then re-render the PNG with rsvg-convert -z 1.5 docs/architecture-diagram.svg -o docs/architecture-diagram.png.
Nexus has three layers: a CLI (for humans) and an MCP server (for agents) that talk to three storage tiers, an indexing pipeline that fills them, and a search engine that queries across them.
Human Agent (Claude Code)
│ │
▼ ▼
CLI (cli.py) MCP Server (mcp_server.py)
│ │
└──────────┬──────────────┘
│
├── Index: classify → chunk → embed → store
│ code: classify(SKIP|CODE|PROSE|PDF) → tree-sitter AST → context prefix → voyage-code-3 → code__<repo>
│ prose: SemanticMarkdownChunker (md) or line-split → voyage-context-3 → docs__<repo>
│ rdr: SemanticMarkdownChunker → voyage-context-3 → rdr__<repo>
│ pdf: auto-detect routing (Docling → MinerU → PyMuPDF) → table/formula detection → bib enrichment → voyage-context-3 → docs__<corpus>
│ skip: .xml/.json/.yml/.html/.css/.lock/etc → silently ignored
│
│ Model names above (`voyage-code-3`, `voyage-context-3`) label the
│ target model, not a client-side API call — the embed request is
│ always issued through the nexus-service (`HttpVectorClient` →
│ `/v1/vectors`): cloud installs route to Voyage AI server-side,
│ local installs use the bundled bge-768 ONNX model. Direct
│ client-side Voyage calls (`_voyage_with_retry`) are retired code
│ with no live caller.
│
├── Search: query → retrieve → rerank → topic-boost → group → format
│ semantic, hybrid (+ frecency + ripgrep)
│ topic boost: same-topic -0.1, linked-topic -0.05 distance adjustment
│ topic grouping: T2 assignments (>50% coverage) → fallback Ward clustering
│
├── Taxonomy: T3 embeddings → HDBSCAN → T2 topics → centroid ANN → incremental assign
│ discover: nx index repo (auto) or nx taxonomy discover (manual)
│ assign: taxonomy_assign_hook fires on every store_put
│ boost+group: search_engine.py reads db.taxonomy per search call
│
├── Catalog: engine Postgres truth (Liquibase schema, RLS) → HttpCatalogClient → typed link graph
│ documents: tumbler addressing (1.owner.doc)
│ links: cites, implements-heuristic, supersedes, relates, formalizes
│ auto-generate: citation links (bib metadata), code-RDR (heuristic)
│ surfaces: MCP nexus-catalog server (10 tools) + nx catalog CLI
│
└── Storage tiers ([RDR-120](rdr/rdr-120-storage-substrate-split.md) substrate split; service-mediated)
T1: nexus-service HTTP (HttpScratchStore; session scratch, shared across agent processes; PG-only, no in-process opt-out — nexus-4lkmz)
T2: nexus-service over Postgres (the write arbiter)
Eight domain stores + the catalog, all HTTP clients behind T2Database
Transport: HTTP to the nexus-service
(the SQLite + FTS5 `nx daemon t2` daemon is RETIRED — it
arbitrated a single SQLite writer; Postgres does that now)
memory · plans · taxonomy · telemetry · document_aspects ·
aspect_queue · document_highlights · catalog
(chash_index RETIRED — table dropped by RDR-187, v0.1.51)
T3: Postgres 17 + pgvector behind the native nexus-service ── nx daemon service start
Same service in BOTH modes; embedding is server-side
(bge-768 in local mode, Voyage in managed-cloud mode).
The client is HttpVectorClient over /v1/vectors; the legacy
ChromaDB serving path is retired ([RDR-155](rdr/rdr-155-pgvector-t3-consolidation.md)).
code__* voyage-code-3 (managed) / bge-768 (local)
docs__* voyage-context-3 CCE (managed) / bge-768 (local)
rdr__* voyage-context-3 CCE (managed) / bge-768 (local)
knowledge__* voyage-context-3 CCE (managed) / bge-768 (local)
Service-mediated T3 storage (RDR-155). T3 serving routes through the
native nexus-service (Postgres 17 + pgvector + server-side embedding) in
BOTH local and managed-cloud modes. make_t3() returns an
HttpVectorClient by default; the client reads NX_SERVICE_URL +
NX_SERVICE_TOKEN with supervisor-lease discovery
(storage_service_addr.<uid>). Start it via nx daemon service start. The
older ChromaDB serving path (nx daemon t3) is GONE — deleted at
RDR-155 P4b (nx daemon has no
t3 subcommand; frozen Chroma directories left on disk are relics — nothing
reads them and there is no path back to that era; delete them when convenient). T2 domain stores serve through the same service
backend (RDR-152); the SQLite + FTS5 substrate (RDR-120) is
deleted (RDR-158 P4) and its NX_STORAGE_BACKEND=sqlite opt-out hard-errors (P3).
One-service convergence. Both tiers now serve through the native
nexus-service: T3 vectors on Postgres + pgvector, and the T2 domain stores
hard-default to the service backend as of RDR-152 (nexus-gmiaf).
NX_STORAGE_BACKEND[_<store>]=sqlite is retired (RDR-158 P3 — a hard error
with the stranded-install redirect); the SQLite stores and their single-writer
daemon are deleted. One service backs both tiers.
For container deployments (Claude Co-Work and similar): containers reach the
host's nexus-service for BOTH tiers via NX_SERVICE_URL +
NX_SERVICE_TOKEN. The separate T2 transport (NX_T2_ADDR /
NX_T2_SOCK, pointed at the T2 daemon's loopback TCP or UDS socket) is gone
with that daemon — one URL now covers what took three variables. Pattern:
# macOS Docker Desktop:
docker run --rm \
-e NX_SERVICE_URL=http://host.docker.internal:<service_port> \
-e NX_SERVICE_TOKEN=<token> \
<image-with-conexus>
# Linux (default bridge):
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-e NX_SERVICE_URL=http://host.docker.internal:<service_port> \
-e NX_SERVICE_TOKEN=<token> \
<image>
UDS-mount works on native Linux Docker (validated by nexus-3d1ph
MVV) but NOT through Docker Desktop's macOS/Windows VM file-
sharing layer (returns ENOTSUP); use the TCP path when the
host is macOS or Windows.
For the full container-integration story (operator-facing setup,
Claude Cowork SDK transport, diagnostic recipes, failure-mode
table) see docs/container-integration.md.
Data flows upward (T1 → T2 → T3).
Unified daemon-lifecycle substrate (RDR-149). The three tiers
differ in storage engine and scope (T1 uid-scoped HttpScratchStore, T2
uid-scoped Postgres, T3 uid-scoped pgvector, all behind the nexus-service) but share
one lifecycle substrate: the leased / fenced / atomic service registry in
src/nexus/daemon/service_registry.py
(ServiceRegistry + ServiceSupervisor). Owner discovery, single-writer
election, ungraceful-death reap, restart fencing, self-heal re-assert, and
version-skew cycling all live in that one primitive, parameterized by tier
and scope. The surviving tiers on this primitive are the storage service
(daemon/storage_service_daemon.py) and the aspect-worker
(daemon/aspect_worker_daemon.py). Three per-tier daemons that used to sit
here are gone: daemon/t3_daemon.py (ChromaDB, RDR-155 P4b),
daemon/t2_daemon.py (SQLite single-writer, nexus-i711w), and
daemon/t1_lease.py (the RDR-149 P4 ServiceRegistry(tier="t1") MCP-lifespan
publisher, re-keyed transient server_pid → session-id, retired
nexus-8zfwv 2026-08-07). T1 does not ride this primitive at all any more —
its live session lease is a standalone flat file
(nexus.db.t1.publish_t1_session_lease, t1_session_lease.<session_id>),
outside ServiceRegistry, with no election flock and no re-key protocol.
Liveness for the tiers that DO ride the primitive is lease freshness
(TTL), not pid — a dead owner's lease ages out, giving pid-reuse immunity.
MinerU (daemon/mineru_lifecycle.py, nexus-1qdb9) consumes the substrate's
public election() spawn guard rather than a full lease: the PDF pipeline's
ensure_mineru_running() elects exactly one spawner per config dir across
concurrent indexing runs (policy-gated by pdf.mineru_autostart /
NX_MINERU_AUTOSTART, remote-URL-safe, shared warm-up budget). The external
mineru-api binary cannot heartbeat its own lease, so full membership
(publish/heartbeat, conformance TIERS) is tracked as nexus-4yohu.
This collapsed a recurring bug class (the same discovery/single-writer/
self-heal/version-skew defect kept reappearing in whichever tier had not yet
received a per-tier fix). The standing gate: any future lifecycle fix
lands in the shared primitive plus the cross-tier conformance suite
(tests/daemon/test_rdr149_lifecycle_conformance.py),
never in a single tier's copy. See
src/nexus/daemon/AGENTS.md for the full
rule and the lifecycle-change checklist.
The catalog is a document registry that sits alongside T3, and the split is
deliberate: T3 stores document content as vector embeddings, addressed by
content hash, while the catalog stores document metadata (title, author,
collection, tumbler address) and relationships (citations, implementations,
supersedes) as a graph of nodes agents can traverse without touching vectors.
This is the git/IPFS-style blob (T3 chunk) + tree (catalog manifest) split:
T3 chunks are content-addressed blobs with no notion of document structure or
order; the catalog's document_chunks manifest is the sole source of truth
for which chashes compose a document and in what order.
Indexing (nx index repo, nx index pdf, nx index rdr) and MCP store_put
auto-register entries via catalog hooks (see Post-Store Hooks
below for the hook contract that wires catalog registration, chash dual-write,
and taxonomy assignment together on every write). Agents use the catalog to find
which T3 collection a document lives in (catalog_search → physical_collection),
traverse typed links (catalog_links, e.g. link_type="cites"), and scope
semantic search to relevant collections instead of searching everything.
See docs/catalog.md for the catalog data model — tumbler
addressing, span formats (chash:<hex> content-addressed spans vs.
positional line/char spans), link types, and the admin/maintenance CLI
surface. The document_chunks manifest itself — one row per
(doc_id, position, chash), atomic-REPLACE per document — is described
inline below and in Index-run fence below; it
is not separately documented in catalog.md.
PgVectorRepository#delete (the engine method every T3 chunk delete funnels
through) is anti-join-scoped against the catalog manifest (RDR-191 F10c, bead
nexus-o8dil.5): a chash is deleted only when NO LIVE catalog_document_chunks
row in that (tenant, collection) still references it (the same anti-join
idiom nexus.gc_quarantine_orphans uses). "Live" excludes tombstoned owners
(a manifest row whose owning document is soft-deleted does not count as
still-referenced), mirroring the file's own liveChunksCondition idiom.
By RDR-108 design, identical chunk text in a collection collapses to one row
shared by every document that contains it, so an unscoped delete of
document A's chunks could silently destroy a chunk document B's manifest
still referenced, with no error, surfacing later as fetchDocumentChunks's
IllegalStateException on B's now-dangling row. The anti-join is what closes
that hole, and it is unconditional, not opt-out.
The trade this makes: over-retention is unbounded in time, not "until the
next GC pass." nx t3 gc treats ANY manifest reference as "keep," dangling
or not, so a chunk the anti-join retains because of an already-dangling
manifest row stays until the manifest is reconciled (work that does not run
automatically). The retained chunk is not silently lost (the prior bug); it is
visibly, but indefinitely, stuck until nx catalog reconcile or a re-index
repairs the manifest.
Ordering is load-bearing for every caller. Because the anti-join counts a
document's own not-yet-tombstoned manifest row as "live," a caller that wants
to delete a chunk it owns must retract that manifest row FIRST: see
reap_catalog_manifest_for_chashes in the Catalog module-map row above.
Retracting after the chunk delete (the pre-nexus-o8dil.5 order) means the
manifest row is still live at delete time, so the delete is silently refused
and the after-the-fact retraction tombstones a document whose chunk never
actually left T3. nx store delete and nx store expire's --ttl reap both
follow the corrected reap-then-delete order; see
cli-reference.md § nx store for the resulting
user-facing exit-code and reported-count contract.
Tumbler grammar (nexus-v3w9n, catalog-034, 2026-08-28; amended twice same
day — segment COUNT not numeric content, then boundary not schema). An
owner prefix (catalog_owners.tumbler_prefix) is exactly 2 dot-separated,
non-empty, non-blank, dot-free segments (e.g. 1.7, bt.1); a document
tumbler (catalog_documents.tumbler) is 3 or more. Segment CONTENT need
not be numeric on the ENGINE side — numeric-ness is the Python client's
Tumbler.parse concern (int-segmented), enforced there, unchanged, and
narrower: nx catalog show / catalog_show's depth-2-is-owner branch
only fires for a numeric tumbler, so a mnemonic owner prefix (bt.1) is
invisible to it. This is disclosure, not a live gap: mnemonic owner
prefixes exist only in the engine's own Java test fixtures — the
2026-08-28 production census found 72 owners, every one shaped 1.N. See
src/nexus/catalog/AGENTS.md's grammar
bullet for the full disclosure. Never widen the engine grammar.
Enforced at the engine's HTTP API boundary (CatalogHandler's
TumblerGrammar validator, HTTP 400 {"rule": "tumbler-grammar", "field", "value"}), NOT by a schema CHECK — Amendment 2 (owner decision) deferred
the two CHECK constraints (catalog_owners_prefix_grammar_ck /
catalog_documents_tumbler_grammar_ck) to nexus-ia69x after the full
engine-suite measurement showed the test corpus itself is shaped
1-segment-owner / 2-segment-document throughout — raw-SQL fixtures and
shared scaffolding included, well beyond what a syntactic census could
find (1959 tests, 331 broken across 46+ classes). Every external producer
enters through CatalogHandler's HTTP routes (legacy /register,
/owners/upsert, /import/owner, /import/document, /doc/register,
/doc/register_many — see TumblerGrammar's own javadoc for the full
route-by-route VALIDATED/LOOKUP-ONLY table; /import/document was a
ship-blocker gap closed in fix round 1); internal minting (ownerPrefix + "." + seq) already conforms by construction, so the boundary is where an
illegal shape can actually be introduced today. The two batch routes
(/import/owner, /import/document, /doc/register_many) validate every
row before the single repository call — one bad row anywhere refuses the
whole batch with zero partial writes. Fix round 1 also closed a
whitespace-only-segment gap ("1. " validated as conforming under a bare
isEmpty() check) — TumblerGrammar now rejects blank segments too, and
the deferred CHECK predicates named in catalog-034's header carry the
same \s-excluding fix so nexus-ia69x inherits the closed gap.
catalog-034-tumbler-grammar.xml still carries the data changeset that
tombstones the two live 2026-05-22 phantom registrations (1.1/1.2,
registered under a nonexistent 1-segment owner) — that step is
independent of the deferred CHECKs and ships regardless. nx catalog show / catalog_show resolve a depth-2 tumbler as an owner card rather
than a document lookup (see cli-reference.md § nx catalog show);
the JSON form carries an explicit "kind": "owner" discriminator (fix
round 1) so a consumer never has to infer owner-vs-document from key
shape.
Tumbler allocation and next_seq. A tumbler's trailing segment is a
sequential number allocated per owner via SELECT ... FOR UPDATE on
catalog_owners.next_seq (CatalogHandler.java); the column tracks the
last-claimed value, not the next one. WAN round-trip cost on
high-volume single-doc allocation motivated the register_many batch path
(http_catalog_client.py). nx doctor ships a drift check
(health.py _check_next_seq_drift) that compares each owner's stored
next_seq against the highest child tumbler actually observed; the
converge route POST /v1/catalog/owners/sweep_next_seq_drift
(CatalogHandler.java) floors every drifted owner's next_seq back to a
safe value across all owners in one call.
RDR-191 Phase 6 update (bead nexus-o8dil.33), 2026-08-15. The
manifest-chunk FK this section originally described as "planned" is
SHIPPED and VALIDATEd (catalog-029-manifest-chunk-fk.xml, deployed
engine-service-v0.1.76) — shape (a) below (the real defect class) is
now REJECTED by the database at write time, not merely detected. The
detection apparatus this section names —
nexus.manifest_verify(doc_id)/manifest_verify_all(),
nexus.manifest_orphans(dim), and health._check_dangling_manifests() —
is RETIRED as a consequence (catalog-030-retire-manifest-verify.xml
drops manifest_verify_all()/manifest_orphans(dim)/manifest_backfill()
outright). nexus.manifest_verify(text) itself is the ONE exception: it
stays, because CatalogRepository.completeIndexRun depends on it
internally for a different completeness question (referenced == the
caller's claimed chunk_count) the FK does not answer. The three-shape
taxonomy below remains the correct conceptual model of the row — it is
retained for that reason — but the specific instruments named throughout
this section are historical except where noted.
"Dangling manifest row" has three distinct shapes, only one of which is a
defect. A catalog_document_chunks row c is examined against its owning
document d (d.tenant_id = c.tenant_id AND d.tumbler = c.doc_id) and
against whether (c.tenant_id, c.collection, c.chash) resolves in
nexus.chunks, filtered to the embedding_<dim> column its collection
routes to (RDR-191 Phase 4: the three per-dim chunks_384/768/1024 tables
were unified into one nexus.chunks table with three nullable typed
embedding_384/embedding_768/embedding_1024 columns under an
exactly-one-populated CHECK, plus three unconditional full HNSW indexes,
one per column):
- (a) Owner LIVE, no matching chunk row. REAL dangling — the class every
producer fix and every gate targets. This is the definition
nexus.manifest_verify(doc_id)/manifest_verify_all()(catalog-020-index-run-fence.xml:167-176/:240-248, both joind.deleted_at IS NULL) andnexus.manifest_orphans(dim)(catalog-004-manifest-functions.xml:100/:116/:132, same join) already use, and the onehealth._check_dangling_manifests()(src/nexus/health.py:3837) inherits by callingmanifest_verify_all()verbatim. - (b) Owner TOMBSTONED. Not dangling — the soft-tombstone contract
working as designed (
delete_documentdeliberately leavescatalog_document_chunksin place,src/nexus/catalog/store_hook.py:738-741). These rows awaitnx catalog purge-trash --no-dry-run --confirm's CASCADE reap and are excluded from (a)'s instruments by construction, not by omission. - (c) Owner ABSENT. Impossible:
catalog_document_chunks (tenant_id, doc_id) -> catalog_documents (tenant_id, tumbler) ON DELETE CASCADE(fk-001-catalog-cross-store.xml:69) is a live Postgres FK already — distinct from the manifest-row-to-chunk-row edge ((tenant_id, collection, chash)againstnexus.chunks), which remains application-enforced only until a future FK lands (plannedMATCH SIMPLE, per RDR-191).
A raw anti-join with no d.deleted_at IS NULL join (as run ad hoc, or as
catalog-025-collection-not-null.xml's one-time cleanup migration
deliberately does) counts (a) ∪ (b) together. That is correct for a
backward-looking sweep — it has no reason to leave tombstone residue sitting
in a table it is already touching — but it is a different, larger population
than (a) alone, and the two must never be compared as if they measured the
same thing. purge-trash's stranded-chunk preview is a different axis
again: direction chunk→parent (existing nexus.chunks rows with no LIVE
manifest referrer), disjoint from all three shapes above by construction —
a clean reading on one instrument says nothing about any other
(health.py:3880-3890). Full reconciliation of specific measured
discrepancies (e.g. 37 vs. 2,951 vs. 6,501 across different RDR-191 GATE-2
census runs) plus the per-instrument definition table: T2
nexus/rdr-191-dangling-definition-of-record.
Chunk identity: the canonical chash (RDR-180)
A chash IS the 32-byte SHA-256 digest of the chunk text — the full digest, never truncated. It has exactly two representations, with a hard rule about which appears where:
- Storage form: 32 raw bytes. Postgres
BYTEAwithCHECK (octet_length(chash) = 32). Content-addressable storage keys on the value, not its rendering; binary makes the width unambiguous (bytes are not characters) and halves the key width vs hex text. - Interchange form: 64 lowercase hex chars. JSON wire values, the
chash:[0-9a-f]{64}citation grammar, CLI display, log lines. Hex belongs on the wire, never in the key column.
One encode/decode seam, everywhere. All conversions between the two forms go through a single boundary pair per side — nothing else encodes or decodes:
| Side | Storage → interchange | Interchange → storage |
|---|---|---|
Python client (chunk_identity.py) |
to_citation_hex() |
to_storage_bytes() |
Java engine (db/Chash.java) |
Chash.toHex() |
Chash.fromHex() / Chash.fromSha256Bytes() |
Width validation lives inside that seam (the type constructor / the helper), so a wrong-width value fails loudly at the boundary with the offending length — never deep inside a transaction.
Why this is written down (the bug class this eliminates): historically the
stored chunk id was sha256(chunk_text).hexdigest()[:32] — 32 hex chars =
128 bits = half the digest — while the citation grammar advertised the full
64-hex digest, bridged by silent truncation. "32" meant hex-chars in one place
and bytes in another. The canonical definition above makes the two subsystems
agree at the full 256 bits, by construction.
Migration status: the flip SHIPPED (RDR-180 closed 2026-07-20; epic
nexus-jxizy): the producer emits the full digest, the engine stores bytea,
and the chash-rekey ladder rung rekeyed existing stores (254,846
production rows, zero loss). For every rehashable row the legacy 32-hex was
the strict prefix of the new 64-hex (same text, same digest); the
chash_alias table was the collision-free resolver for legacy references in
that window. Retired (nexus-lgdel.l1, 2026-08-16): the beneficiary
population reached zero, so chash_alias and the whole legacy-reference
resolution route are DROPPED — a legacy 32-hex reference is no longer
resolvable at all; re-index the source to mint a canonical 64-hex chash.
Combined-query shapes (RDR-156 Decision 5)
Four MCP tools unify an app-side stitch (vector search, then a second round trip against the catalog or aspects store) into ONE planner-optimizable SQL statement, each backed by a per-embedding-dim LANGUAGE sql STABLE SECURITY INVOKER Postgres function (nexus.search_<shape>_384/768/1024) that takes the query vector as a plan-time argument so the HNSW index survives the join:
| Tool | Function | Joins in | Retires |
|---|---|---|---|
search_metadata_scoped |
search_metadata_scoped_<dim> |
catalog_document_chunks + catalog_documents |
query's catalog-routing dance (content_type/author/year/corpus/subtree/chunk where) |
search_topic_scoped |
search_topic_scoped_<dim> |
topic_assignments + topics |
app-side topic-label filter-then-rank |
search_graph_hop |
search_graph_hop_<dim> |
a WITH RECURSIVE BFS over catalog_links, then the two tables above |
query's follow_links app-side graph BFS + per-collection search + re-join |
search_aspect_scoped |
search_aspect_scoped_<dim> |
the two tables above + document_aspects (on doc_id = tumbler) |
search + operator_filter(source="aspects"), for the case where the aspect predicate is selective — that two-step path filters AFTER the vector top-N truncation and can silently miss a distant match the predicate would otherwise keep |
search_aspect_scoped's join runs on document_aspects.doc_id, not source_uri — a precondition the other three shapes don't share. A document whose aspects row has no doc_id never joins and is silently excluded from this shape — by design, not a bug; those rows remain reachable through the older operator_filter(source="aspects") path, which is keyed on source_uri and unaffected. Its field allowlist (problem_formulation, proposed_method, experimental_datasets, experimental_baselines, experimental_results) deliberately excludes extras and salient_sentences: both were converted TEXT -> jsonb by aspects-003-type-hygiene.xml (RDR-194), so a five-field allowlist, not the seven aspects-001-baseline.xml originally defined as TEXT, is correct here — matching the pre-existing AspectRepository.ALLOWED_ASPECT_COLUMNS precedent.
doc_id coverage is real, not edge-case drift. doc_id is populated by the one-time aspects-004-doc-id-backfill.xml changeset, which attributes a row only when document_aspects.source_uri is byte-for-byte equal to the catalog document's own source_uri. That holds for file-keyed corpora (code__/docs__/rdr__, both sides key off the same file:// path). For knowledge__ collections — the dominant aspects corpus — it structurally does NOT hold: the catalog registers those documents' source_uri from the document title (src/nexus/catalog/store_hook.py:286), while the aspect extractor's source_uri is built from source_path (src/nexus/aspect_readers.py:172), often itself a content-hash string. Title and source_path are two different identity fields for the same document family, not the same field spelled two ways, so an exact match essentially never occurs there — the large majority of knowledge__ document_aspects rows stay doc_id NULL after the backfill and are invisible to search_aspect_scoped until re-extracted under nexus-x1de2's go-forward stamping (which keys off the extraction queue's own doc_id, not source_uri, so it does not retroactively fix these rows either). Gap-fill for the knowledge__ family is tracked as nexus-bocft.
A frecency-boosted fifth shape named in the RDR was retired before implementation (2026-08-28 disposition) — frecency boosting is a ranking adjustment, not a combined query in this family's sense.
Two hash fields look similar but mean very different things. Confusing them produces false-positive panic findings (e.g. "94% redundancy across the corpus" turns out to be 94% of chunks share a doc-level hash, which is correct: every chunk of one paper has the same content_hash). The table below locks the contract; consult before drawing conclusions from a metadata distribution.
| Metadata field | Level | Keyed on | Set by | Used for |
|---|---|---|---|---|
content_hash |
document | sha256(file_bytes) |
every indexer at register time (indexer.py:1198) |
document-level dedup; staleness comparison — paired with the index-run fence's three-way state, see Index-run fence (RUNFENCE) below; backup-snapshot identity |
chunk_text_hash |
chunk | sha256(chunk_text) (full 64 chars) |
every indexer per chunk; healed on an upgraded store by the ladder (nx upgrade) |
content-addressed link spans (chash:<hex>); nx t3 reidentify natural-ID source (first 32 chars); cross-collection chunk dedup |
chunk_text_hash (as chunk id) |
chunk | the full SHA (RDR-180) | every indexer via chunk_identity.chunk_id |
the chunk natural ID and the document_chunks.chash join key; the pre-RDR-180 [:32] truncation is retired, and so (nexus-lgdel.l1) is the chash_alias legacy-reference resolver that used to bridge it — a legacy 32-hex reference is unresolvable now, re-index the source |
source_uri |
document | file://... or x-devonthink-item://<uuid> etc. |
indexer / MCP write paths | persistent URI identity; aspect-extraction routing; audit-membership home detection |
source_path |
document | absolute or repo-relative file path | indexer | display + grep targets; legacy path predating source_uri |
chunk_start_char / chunk_end_char |
chunk | char offsets in the source file | indexer per chunk | chunk:char span resolution; UI highlight |
section_title / section_type |
chunk | tree-sitter / Markdown section header | code/prose chunkers | search-time filtering (section_type!=references) |
embedding_model |
document | model id string | every write through the T3 client (HttpVectorClient; T3Database/db/t3.py is the retired serving path kept only as a test facade — see Storage row above) |
voyage-code-3 vs voyage-context-3 routing; embedding-model drift/staleness detection (nx doctor, indexer_utils.py re-embed check) |
extraction_method |
chunk | PDF-extractor identity string | PDF chunks only, via pipeline_stages._enrich_metadata_from_extraction post-pass (streaming) / doc_indexer._pdf_chunks (legacy batch path) |
retroactively scoping extractor regressions — docling | mineru | pymupdf_normalized, or the honest mixed aggregate mineru+docling-degraded when an --on-formula-oom docling per-page degrade fired (nexus-1oguj, new-writes-only — see epistemic-hole note below) |
doc_id, chunk_index, and chunk_count were ALSO chunk-level metadata pre-RDR-108. RDR-108 Phase 3 retired them; the catalog document_chunks manifest is the single source of truth for chunk position within a document. Read paths that need chunk order consult Catalog.get_manifest(doc_id) (see _attach_doc_ids_from_catalog in search_engine.py for the standard fallback).
Legacy fields (corpus, store_type, expires_at) were dropped in RDR-101 Phase 5c. They are not present in current writes; older collections still carry them as cargo until nx t3 reidentify runs the canonical-schema funnel and normalizes them away. extraction_method is NOT one of these — nexus-1oguj (2026-08) promoted it from dropped-cargo to canonical; the field existed at extraction time long before that fix but was discarded before storage, which is exactly the gap nexus-1oguj closed.
Epistemic hole (nexus-0qc4b): extraction_method is new-writes-only, with no backfill. Chunks indexed before nexus-1oguj carry no extraction_method key at all — not an empty string, absent entirely. A query that treats "key absent" as "not mineru" (or as any other negative extractor claim) silently conflates unknown provenance with a known answer. Re-extraction would be required to recover the value honestly for old chunks, so there is no cheap backfill; scoping "all mineru-extracted documents" is correct only as of the fix's ship date, and only for chunks written after it. Treat absence as unknown, never as evidence.
For operator runbooks built on this vocabulary see docs/operations/t3-health.md (when nx catalog doctor reports X) and docs/operations/audit-membership-interpretation.md (the 3 contamination axes).
(nexus-5xn3k). Chunk-level content_hash matching (the metadata-table row above) has one
blind spot: a run that dies partway through leaves T3 and the catalog
manifest consistently truncated — every artifact the staleness check
could compare agrees with every other, because all of them were written by
the same broken run. No amount of comparing two truncated artifacts to
each other reveals the truncation. The index-run fence adds a
document-level record of intent (a run started) and verified
completion (a run finished and was checked whole), orthogonal to whether
the content itself changed.
Fence fields on catalog_documents, all nullable/empty-default —
absent on rows written by a pre-fence engine or before a document's first
fenced index:
| Field | Meaning |
|---|---|
index_state |
NULL (unknown/legacy) | 'indexing' | 'complete' | 'failed' |
index_content_hash |
the content_hash the last completed run verified |
index_run_id |
opaque id for the run that last touched the fence |
index_started_at |
timestamp the current 'indexing' state began |
Not a lock. index_state='indexing' is advisory only (nexus-lcmbp
non-goal) — it never means "someone else is running, skip." A retry or a
second concurrent run simply re-stamps the same shape. What it guarantees
is the opposite of a lock: a document can never read as done while a run
against it is in flight or has failed. Since nexus-bhlfy (2026-08-17) every
indexing producer — the repo-walk ChunkBatcher path and the three legacy
fallbacks, not just doc_indexer — pairs its _fence_begin with a
_fence_fail arm, and the repo path's staleness check treats
index_state IN ('indexing','failed') as stale regardless of content-hash
match (nexus-cp46b), so a stranded fence always drains on the next normal
run.
Lifecycle, driven client-side (src/nexus/doc_indexer.py:
_fence_begin / _fence_complete / _fence_fail) against three engine
routes (CatalogHandler.java):
| Route | Client call | Effect |
|---|---|---|
POST /v1/catalog/index-run/begin |
_fence_begin |
Stamps index_state='indexing' BEFORE the first chunk upsert. Idempotent; advisory-only — a 404 (pre-fence engine) is swallowed with a WARNING and indexing proceeds unaffected |
POST /v1/catalog/index-run/complete |
_fence_complete |
Fail-closed. The engine re-runs the manifest-verify predicate (missing == 0 AND referenced == chunk_count) inside the SAME transaction as the stamp — an advisory lock (CatalogRepository) serializes this against concurrent manifest writes for the same doc. Only on success does index_state flip to 'complete' with index_content_hash set. On refusal the engine returns HTTP 409 and the client raises IndexRunVerifyRefused; index_state is left untouched (still whatever it was), never silently marked done |
POST /v1/catalog/index-run/fail |
_fence_fail |
Stamps index_state='failed' from the caller's own exception handler; never raises itself, so a fence-write problem can't mask the original indexing error |
All three routes refuse against a tombstoned document.
The three-way staleness gate (doc_indexer._index_run_fresh) layers on
top of the pre-existing chunk-level content_hash + embedding_model
match (a limit=1 probe against one surviving chunk) — it never replaces
that check, it closes the blind spot on top of it:
index_state == 'complete'ANDindex_content_hash == content_hash→ definitely fresh, no further probe.index_state in ('indexing', 'failed')→ definitely stale, no probe — a partial or errored run must never read as done regardless of what T3 happens to hold right now.- Anything else (
NULL— legacy row, pre-fence engine, unresolvabledoc_id, or a fence-read failure) → the fence has nothing to say; fall through to the pre-RUNFENCE behavior, onemanifest_verifycall against the engine (_manifest_is_fully_present).
Completion riding the manifest write (nexus-5xn3k.4). The hot indexing
path rarely calls /index-run/complete directly — completion instead
rides the existing flush-grain manifest write, at zero extra round trips:
hook_registry.HookRegistry.fire_batchaccepts an optionalmanifest_complete: dict[doc_id, content_hash]— the producer's file-atomic assertion that a document is WHOLLY contained in this batch. It is threaded only to hooks that declare amanifest_completeparameter (a registration-time signature classification, mirroring the existingcatalog_doc_iddispatch — see Post-Store Hooks below).manifest_write_batch_hook(the sole declaring consumer) forwards the map toHttpCatalogClient.write_manifest_many(docs, complete=...). On an engine that supports it (v0.1.62+), each doc'scompleteentry runs the identical fail-closed verify inside the per-doc write transaction — no second POST. A refusal does not fail the write (the manifest rows are correct; the contract is over-work-never-under-work): the refused doc lands in the response'scomplete_refusedlist instead, alongside acomplete_refused_countscalar carried separately so a truncated list is detectable — callers must parse both, since a refused doc is not fully indexed.- Production takes the per-doc path, not the batch ride (nexus-dcv2k):
the deployed writer,
_ServiceCatalogWriter, does not exposewrite_manifest_many(the op is absent from both its write-op allowlists), somcp_infra._manifest_write_loop's capability check for the batched ride is always False in production. The completion stamp instead lands viamcp_infra._stamp_index_run_complete, which callscomplete_index_runper document with the same fail-closed contract and records the same refusal shape — both paths feed one collector,mcp_infra.get_complete_refusals(), so the summary consumer sees a refusal regardless of which branch stamped it. - A document the producer claims complete that turns out NOT to start at
chunk position 0 (a continuation slice, not the whole file) is a
contract violation the client refuses to stamp — logged loudly
(
manifest_complete_claim_on_continuation_slice), never silently accepted.
Reads — RETIRED, RDR-191 Phase 6 (nexus-o8dil.33), 2026-08-15.
HttpCatalogClient.manifest_verify(doc_id) (one document — formerly also
nx catalog manifest-verify) and manifest_verify_all() (every live
document, grouped by collection — the former nx doctor sweep primitive)
are both retired: the manifest-chunk FK makes the dangling state they
diagnosed unreachable. doc_indexer._manifest_is_fully_present is now an
unconditional return True (the FK makes its underlying question
provably always-false) — see that function's own docstring for the full
argument, including why CatalogRepository.completeIndexRun's still-live
write-path use of the SAME underlying nexus.manifest_verify(text) SQL
function answers a different question the FK does not.
Doctor axis. nx doctor separately flags documents stranded in
index_state='indexing' past a threshold (health.py _check_stale_indexing_runs) — a distinct failure class from a
manifest-verify miss: missing chunks vs. a fence that never cleared (e.g.
a rolling engine deploy that straddles one multi-batch run's begin/complete
pair, stranding the document in 'indexing' until a future full re-index
happens to route both calls through upgraded pods).
CLI — RETIRED, RDR-191 Phase 6 (nexus-o8dil.33). nx catalog manifest-verify TUMBLER_OR_TITLE used to report one document's
referenced/present/missing chunk counts plus its fence state without
a full corpus scan; see
cli-reference.md § nx catalog manifest-verify — retired
for the current remedy (nx catalog show TUMBLER_OR_TITLE's index_state).
Tumbler comparison semantics and the two graph-traversal views
(catalog_links vs catalog_link_query) are documented in
docs/catalog.md and
docs/catalog.md § Admin and maintenance.
The document_chunks manifest schema is covered above (§ Metadata field
semantics, § Index-run fence), not separately in catalog.md. The
ChashIndex routing table is retired (RDR-187): the PG table
nexus.chash_index was dropped as of engine v0.1.51. chash_alias, the
legacy-reference resolver that briefly survived it, is itself retired
(nexus-lgdel.l1, 2026-08-16 — see
Chunk identity above) —
catalog.md's own ChashIndex/migration-runbook material predates both drops.
nx t3 reidentify (still a live command, commands/t3.py) walks a
collection's chunks to backfill/normalize legacy chunk ids; it is retained
for the chunk-identity history
(RDR-053/RDR-108)
rather than as a routine operator step. Post-RDR-155,
T3 serves through pgvector + nexus-service, and the upgrade path is
nx upgrade — the single trigger that walks the
RDR-185 ladder, whose
substrate rung carries Chroma onto pgvector (see
migration-runbook.md for the operator's manual order of
operations).
CCE single-chunk note: For CCE collections (docs__*, rdr__*, knowledge__*), documents with only one chunk are embedded via contextualized_embed(inputs=[[chunk]]).
Taxonomy (RDR-070) builds a topic hierarchy over T3 collections using existing embeddings, without re-embedding. HDBSCAN clusters the vectors already stored in T3 (pgvector via nexus-service), labels them with c-TF-IDF, and persists topic assignments to T2. Every subsequent store_put call assigns the new document to the nearest centroid via ANN lookup. Search then uses these assignments to boost same-topic results and group output.
In local mode, code__* collections are excluded by default because the general-purpose local embedder (bge-768) clusters code poorly. Cloud mode uses voyage-code-3 and is unaffected. (As of 6.0, discovery/rebuild/assignment run on the nexus-service backend per nexus-7ydks; nx taxonomy split/project are still being ported.)
nx index repo / nx taxonomy discover
│
▼
discover_for_collection() # taxonomy_cmd.py
│ fetch ids + texts + embeddings from T3 (page_size=250)
│ fall back to the local ONNX embedder (bge-768) re-embed only when T3 embeddings absent
▼
CatalogTaxonomy.discover_topics() # db/t2/catalog_taxonomy.py
│ sklearn HDBSCAN on N×D float32
│ c-TF-IDF labels (CountVectorizer + TfidfTransformer)
│ persist: topics, topic_assignments → T2 (engine Postgres via HttpTaxonomyStore)
│ upsert cluster centroids → pgvector via nexus-service (HttpCentroidStore)
▼
taxonomy_assign_hook() # mcp_infra.py (fires on every store_put)
│ fetch new doc's T3 embedding
│ CatalogTaxonomy.assign_single(): ANN query against taxonomy__centroids
│ nearest centroid → topic_id → INSERT OR IGNORE topic_assignments
▼
search_cross_corpus() # search_engine.py
│ one POST /v1/vectors/search per embedding-model group (collections of one
│ prefix share a model; nexus-d9xt2), per-collection floor n_results*mult,
│ capped at 300 and split into sub-batches above it; thresholds and
│ diagnostics still resolved per collection from the row's collection tag
│ get_assignments_for_docs(result_ids) → topic_assignments dict
│ apply_topic_boost(): distance -= 0.1 (same topic), -= 0.05 (linked topic)
│ topic grouping when assignment coverage >50%
│ otherwise fall back to Ward hierarchical clustering
T2 tables (engine Postgres via HttpTaxonomyStore, owned by CatalogTaxonomy):
| Table | Purpose |
|---|---|
topics |
One row per discovered topic: label, collection, centroid_hash, doc_count, review_status, terms |
topic_assignments |
doc_id → topic_id mapping, assigned_by (hdbscan or centroid) |
taxonomy_meta |
Per-collection discover stats (last_discover_at, last_discover_doc_count) |
topic_links |
Aggregated inter-topic link counts derived from catalog link graph |
Centroid storage (nexus.taxonomy_centroids, one unified table with three nullable typed embedding_384/embedding_768/embedding_1024 columns under an exactly-one-populated CHECK — RDR-191 Phase 4 unified the three prior per-dim taxonomy_centroids_{384,768,1024} tables the same way it unified T3 chunks): served through pgvector via nexus-service (HttpCentroidStore) since RDR-155 P4a.2. One row per topic holds the centroid vector, collection, topic_id, and label; assign_single() does the ANN lookup. Chroma is not a live substrate in any mode (RDR-155 P4b, shipped 2026-07-25) — the discover/rebuild centroid-write helpers go through HttpCentroidStore like every other centroid path.
| Operation | What happens |
|---|---|
discover |
Creates centroids for all topics in a collection |
rebuild (--force) |
Runs HDBSCAN on updated embeddings, matches new centroids to old via cosine similarity (_merge_labels), transfers operator labels and accepted status |
split |
Replaces the parent centroid with two child centroids |
delete / merge |
Removes orphaned centroid entries |
Manual labels survive rebuild via _merge_labels.
nx taxonomy links --collection <col> reads the catalog link graph and aggregates which topics are connected via document-level links. Results are stored in topic_links and read by the search engine via get_topic_link_map() to apply the linked-topic distance boost (-0.05).
| Command | Purpose |
|---|---|
status |
Health overview: collections, coverage, review state |
discover |
Run HDBSCAN on a collection (auto or manual) |
rebuild |
Re-discover with merge strategy (preserves labels) |
list |
List topics with doc counts and review status |
show |
Detail for a single topic: terms, docs, links |
review |
Interactive accept/reject workflow |
label |
Claude haiku auto-labeling for a collection |
assign |
Manually assign a doc to a topic |
rename |
Rename a topic label |
merge |
Merge two topics into one |
split |
Split a topic on a keyword pivot |
links |
Compute and persist inter-topic links from catalog |
project |
Cross-collection projection with --use-icf hub suppression (RDR-077) |
Projection quality (RDR-077)
topic_assignments also carries similarity (raw cosine), assigned_at,
and source_collection for projection rows. Operator guide:
docs/exploration/taxonomy-projection-tuning.md —
threshold calibration, ICF rationale, upsert semantics, troubleshooting.
| Key | Default | Effect |
|---|---|---|
auto_label |
true |
Run Claude haiku labeling after discover |
local_exclude_collections |
["code__*"] |
Skip these collections in local mode |
Three parallel hook contracts, implemented by the HookRegistry class in src/nexus/hook_registry.py, cover the three real workload shapes for per-document enrichment that fires after a write. All three chains fire from every storage event, MCP store_put and CLI bulk ingest alike; consumers register in exactly one shape based on the grain of work and whether the work benefits from batched dependency calls. Registration happens in one place, hook_registry.install_default_hooks, called once per entry point (constructor-injected, not module-load self-registration). All use the same per-hook failure-isolation pattern (capture, persist to T2 hook_failures with a chain column distinguishing the source, never propagate).
| Shape | Register | Fire | Where it fires from | Current consumers |
|---|---|---|---|---|
| Single-document (RDR-070) | HookRegistry.register_single(fn) |
fire_single(doc_id, collection, content) |
MCP store_put (once per call) and every CLI ingest path (once per doc in the batch) |
empty by default; reserved for future per-doc consumers that key on doc_id |
| Batch (RDR-095) | HookRegistry.register_batch(fn) |
fire_batch(doc_ids, collection, contents, embeddings, metadatas) |
every CLI ingest path with the full batch; MCP store_put with a 1-element batch |
taxonomy_assign_batch_hook (RDR-070), manifest_write_batch_hook (GH #1371 retry/repair, RUNFENCE completion-stamp coupling — see Index-run fence above). Both are flush-grain (batch_grain = "flush"). The chash dual-write hook that used to sit here is retired (RDR-187): the chunks tables are the chash-keyed store now, so there is no derived copy to dual-write; the name is guarded from ever reappearing (see Drift guard below) |
| Document-grain (RDR-089) | HookRegistry.register_document(fn) |
fire_document(source_path, collection, content) |
MCP store_put (once per call) and every CLI ingest path (once per source document) |
aspect_extraction_enqueue_hook (RDR-089: enqueues to aspect_extraction_queue, async worker drains) |
The batch contract exists because some enrichments collapse N dependency calls into one batched call (e.g. taxonomy.assign_batch issues one batched pgvector ANN query via nexus-service for N nearest-centroid lookups; the per-doc path issues N sequential queries). For corpus-scale ingest the difference is roughly 1000x. The single-document chain serves work that does not benefit from batching but keys on doc_id. The document-grain chain serves work that needs the source document boundary as a stable identity (RDR-089 aspect extraction, where each paper is one extraction regardless of chunk count) — its key is source_path, not doc_id, and the chain fires once per source document at every CLI ingest entry point as well as at MCP store_put.
taxonomy_assign_batch_hook accepts embeddings=None from the MCP path and fetches them from T3 inline (with a local bge-768 ONNX fallback when the T3 row is unavailable). One hook body covers both the bulk path and the single-document path; there is no separate single-doc taxonomy hook to keep in sync.
aspect_extraction_enqueue_hook is the document-grain consumer. The hook persists (collection, source_path, content) to aspect_extraction_queue (microsecond-scale T2 INSERT) and lazy-spawns a daemon worker that drains the queue and invokes the synchronous extract_aspects extractor. The async dispatch is necessary because Critical Assumption #2 in RDR-089 (per-document extraction <3 s) was invalidated by the P1.3 spike (median 26.5 s, p95 38.1 s) — synchronous-inline would block the ingest path for ~25 s per document.
The ChunkBatcher-driven nx index repo path (nexus-nj4ch) is the one exception to "fires once per source document at the document-grain call site": there, the per-file fire_document() call for this hook is replaced by one flush-grain aspect_queue.enqueue_many() Postgres round trip per upload batch (falling back to the per-row enqueue() on batch failure, since one row's constraint violation aborts the whole server-side transaction). The other fire_document call sites (doc_indexer.py, pipeline_stages.py, and MCP store_put in mcp/core.py) are untouched and still fire the hook per document.
Content-sourcing contract. The document-grain dispatcher signature is (source_path, collection, content). MCP store_put passes content=<full document text> literally — the text is in scope at the boundary. CLI ingest sites accumulate chunks rather than full documents and pass content="" as the contract signal that the hook may need to read source_path itself. aspect_extraction_enqueue_hook persists content to the queue row when non-empty (covering the MCP path where source_path is a doc_id rather than a real filesystem path) so the worker has the text without re-reading from disk; CLI rows where content was not in scope rely on the worker's source-path-read fallback.
Enqueue identity & loud-failure contract (RDR-172). The doc_id the hook forwards to the enqueue is the catalog document id (tumbler), not the chunk hash. store_put forwards catalog_doc_id — the tumbler catalog_store_hook returns when it registers the note-backed document (mcp/core.py). Forwarding the chunk hash was the silent-failure bug nexus-ov0sw: in service mode the chunk hash is not a registered catalog_documents tumbler, so the queue's doc_id foreign key rejected it and the best-effort hook swallowed the 500. Three rules now hold. (1) Blank doc_id → NULL: the no-catalog case forwards '', which the service nullIfBlanks to SQL NULL (a legitimate "no reference" sentinel) and lands a pending row with HTTP 200. (2) Non-blank unregistered doc_id → typed 4xx, never silent: a non-blank id that is not a registered tumbler is a client bug (RF-8: no race); the service maps the SQLSTATE class-23 integrity violation to a typed 409 ({"error","sqlstate"}, body sanitised) ahead of the generic 500 (AspectHandler.sqlState23) — never a silent NULL coercion, never an opaque 500. (3) Tripwire: the enqueue stays best-effort (never blocks ingest), but the hook's internal swallow would otherwise hide a failure from hook_registry; it therefore persists its own hook_failures row (hook_name='aspect_extraction_enqueue_hook', chain='document') and logs aspect_extraction_enqueue_failed, and the --fullstack ingest E2E asserts zero such rows so the silent-total-failure class cannot regress unobserved.
Registration order. Pre-RDR-187, chash_dual_write_batch_hook was registered before taxonomy_assign_batch_hook — load-bearing, because chash rows had to exist before topic assignment ran. That hook is retired; install_default_hooks now registers taxonomy_assign_batch_hook before manifest_write_batch_hook, but the two are independent (taxonomy reads T3 embeddings, the manifest hook reads chunk metadata from the batch's own indexed_metas) — no ordering constraint remains in the batch chain, nor in the single-document or document-grain chains.
Failure capture. Per-hook exceptions are caught in the fire function, logged via structlog, and persisted to T2 hook_failures. The chain column (T2 4.14.2 migration, RDR-089) carries an enum value of 'single', 'batch', or 'document' distinguishing the chain that fired. Single-document failures store the scalar doc_id in the legacy column. Batch failures store a representative scalar (first id) in doc_id, the JSON-encoded list in batch_doc_ids, and dual-write is_batch=1 for back-compat with pre-4.14.2 readers. Document-grain failures store the source_path in the legacy doc_id column (the column carries 'subject of failure' regardless of chain shape). The nx taxonomy status reader surfaces all three shapes and reports affecting M document(s) whenever a batch row is present (M > scalar count).
Manifest-write retry and repair (GH #1371). manifest_write_batch_hook is a batch-chain consumer (flush-grain), best-effort by the same never-propagate contract as every other hook — not a separate file-grain mechanism outside the chains above. It writes the document_chunks linkage + the chunk_count cache, and since nexus-5xn3k.4 it also carries the RUNFENCE completion stamp when the batch's producer asserts file-atomic completeness (manifest_complete kwarg — see Index-run fence above). A connection-class failure to the catalog engine-service (transient httpx transport/timeout errors, including chained causes) retries with a short bounded backoff (nexus.retry._manifest_write_with_retry: up to 3 retries, 0.5s/1s/2s, ~3.5s worst case) before falling back to the swallow-and-log path; a real 4xx or application error still fails on the first attempt. Failures that exhaust the retry are recorded in a process-local collector (mcp_infra.get_manifest_write_failures) instead of only reaching a structlog WARNING, so nx index's end-of-run summary can surface WARNING: catalog manifest write failed for N document(s) with a pointer to nx catalog reconcile, which rebuilds the missing manifest rows from T3 chunk metadata (content_hash match, char/line-span ordering). A separate collector, mcp_infra.get_complete_refusals(), tracks the RUNFENCE-specific case — manifest rows written correctly but the fail-closed completion stamp refused — fed from both the batched-ride path and the per-doc _stamp_index_run_complete fallback production actually takes. Before the GH #1371 fix a persistent connection blip during indexing left chunk_count > 0 documents with zero manifest rows, silently invisible to catalog-aware retrieval.
Combined write supersedes the two-call shape on the ChunkBatcher flush path (nexus-wxjr6/kl2z6, engine v0.1.69+). The paragraph above describes the general flush-grain shape — chunks upserted, then manifest_write_batch_hook fires a separate write_manifest_many POST — and that shape is still exactly what every other batch producer uses (doc_indexer.py, prose_indexer/code_indexer oversize fallbacks, the exporter, pipeline_stages.py, MCP store_put). The ChunkBatcher-driven nx index repo flush path (code/prose/pdf ingest through that route) is the one exception: _batch_flush now calls a single combined POST /v1/catalog/manifest/write_many carrying chunks + docs + complete + sweep=true in one request, landing each doc's chunks and manifest atomically in one per-doc engine transaction — and manifest_write_batch_hook is explicitly excluded from that flush's fire_batch dispatch (HookRegistry.fire_batch's skip_hooks= param) so the manifest is never double-written. Scope of the atomic shape: it covers only WHOLE-document flushes on this path — _build_combined_write_payload defends the invariant that every doc in a ChunkBatcher flush includes position 0 (raising _CombinedWritePositionZeroViolation rather than silently routing around it if that invariant is ever violated), because ChunkBatcher's own producer guarantees file-atomic flushes by construction. Continuation-sliced documents — a batch whose first chunk is NOT position 0, e.g. a document streamed across multiple flushes by a producer other than ChunkBatcher — never enter this path at all; they take the old two-call append path via _manifest_write_loop (the same function backing manifest_write_batch_hook for every still-two-call caller) and retain the pre-existing non-atomic window until nexus-7t86z (open) closes it. Chunks belonging to a file with no catalog identity (routine in a mixed flush) still ride the old upsert_chunks_with_embeddings call as an orphaned-but-searchable fallback, matching pre-combined-write behavior for that case. Sweep accounting (swept/sweep_skipped/sweep_detail) comes from the engine's own response now, not a local before/after chash diff. Design memo: T2 nexus/design-kl2z6-combined-write.
Drift guard. tests/test_hook_drift_guard.py uses ast.walk to detect any ImportFrom, Attribute, or bare-Name reference to a guarded hook outside the explicit allowlist. Two guards: GUARDED_NAMES = {taxonomy_assign_batch_hook} (allowlist mcp_infra.py + hook_registry.py); DOCUMENT_HOOK_GUARDED_NAMES = {aspect_extraction_enqueue_hook} (allowlist aspect_worker.py + hook_registry.py). A third, separate check (RETIRED_HOOK_NAMES = {chash_dual_write_batch_hook}) asserts the RDR-187-retired chash dual-write hook never reappears anywhere in src/ at all — no allowlist, because the chunks tables are the chash-keyed store and there is no derived copy left to dual-write. String literals, comments, and docstrings are ignored by all three checks. Adding a new per-document or batch enrichment registers through the appropriate HookRegistry.register_batch / register_document entry point (wired at install_default_hooks); a regression where a new module imports a hook directly fails CI. A separate runtime test test_index_pdf_fires_document_hook_exactly_once (in tests/test_doc_indexer.py) drives a sample PDF through index_pdf with a counting probe hook registered, asserting the document-chain fires exactly once per source document — pinning the runtime invariant the AST count guard alone cannot.
Out of scope by design (RDR-095 Decision Rationale, intentional non-twins of the batch-hook pattern):
- Three catalog-registration mechanisms (
_catalog_store_hookincommands/store.py,_catalog_pdf_hookinpipeline_stages.py,indexer.py:250ad-hoc registration) each capture different per-domain metadata: knowledge curator + doc_id for ad-hoc store; corpus curator + 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 on origin. Three legitimate per-domain registrations, not three copies of the same hook. _catalog_auto_linkreads T1 scratch entries taggedlink-contextthat agents seed before calling MCPstore_put. CLI bulk ingest has no equivalent pre-declaration semantics; it uses entirely separate post-hoc linkers incatalog/link_generator.py(generate_citation_links,generate_code_rdr_links,generate_rdr_filepath_links). MCP-only auto-linking is intentional path-shape coupling.
The partial-commit failure mode (a batch hook commits an early sub-step then raises before completing) is documented in RDR-095 Failure Modes. The framework captures the doc_id list and exception per hook invocation; per-sub-step capture is hook-internal, not framework-level. A future RDR can introduce a record_partial_progress helper if a consumer needs it.
src/nexus/db/t2/ is a Python package split into eight domain-specific
stores. Each store is an HTTP client (Http*Store) against the
engine's Postgres — the SQLite twins that used to own tables in a
shared memory.db were deleted in RDR-158 P4 (nexus-i711w), and the
engine's Postgres is the single write arbiter. Cross-store contention
tuning (busy_timeout, WAL single-writer serialization, the daemon
dispatch retry of RDR-129 B1/B2) died with that substrate.
| Store | Class | Attribute | Responsibility |
|---|---|---|---|
| Memory | HttpMemoryStore |
db.memory |
Persistent notes, project context, full-text search, access tracking, TTL |
| Plans | HttpPlanLibrary |
db.plans |
Plan templates, plan search, plan TTL |
| Taxonomy | HttpTaxonomyStore |
db.taxonomy |
HDBSCAN topic discovery, centroid ANN assignment, merge strategy, review workflow (RDR-070) |
| Telemetry | HttpTelemetryStore |
db.telemetry |
Relevance log (query/chunk/action triples), retention-based expiry |
| Chash index | HttpChashIndex |
db.chash_index |
RETIRED (RDR-187): the PG table nexus.chash_index is DROPPED as of engine v0.1.51 — it was the router remnant of the split-store architecture. chash_alias, the legacy-reference resolver that briefly succeeded it, is itself retired (nexus-lgdel.l1, 2026-08-16) — a legacy 32-hex reference is no longer resolvable at all. The client store class remains only as a shim until the final /v1/chash/* 410 flip (nexus-piwya.11) — its own delete_collection method was deleted at nexus-lgdel.l2, the last remaining no-op that route ever served. Historical: global chash → (collection, doc_id) lookup, dual-written at every T3 upsert site (RDR-086 Phase 1) |
| Document aspects | HttpDocumentAspectsStore |
db.document_aspects |
Per-document structured aspects (problem, method, datasets, baselines, results, extras) keyed by (collection, source_path); populated by the async aspect-extraction worker (RDR-089 P1.1) |
| Aspect queue | HttpAspectQueue |
db.aspect_queue |
Durable queue feeding the aspect-extraction worker; FIFO claim_next with cross-process compare-and-swap atomicity; reclaim_stale recovers rows from crashed workers (RDR-089 follow-up) |
| Document highlights | HttpDocumentHighlightsStore |
db.document_highlights |
Per-document DEVONthink highlight / mention markdown notes, keyed by catalog tumbler (doc_id); populated by nx dt index --highlights (RDR-139 Layer E). Deliberately separate from document_aspects: free-text highlights must not contend with the aspect worker's whole-row overwrite or its confidence gate |
T2Database is a composing facade: it constructs the eight stores in
order (memory → plans → taxonomy → telemetry → chash_index →
document_aspects → aspect_queue → document_highlights), re-exposes the memory-domain public
methods as thin delegates for backward compatibility, and runs
cross-domain operations like expire() over all of them. The
chash_index, taxonomy, document_aspects, and aspect_queue domains are
accessed directly via their attributes -- no facade delegates exist
for them. The facade holds no connection of its own; every
operation runs through a specific domain store's HTTP client.
Preferred call style for new code:
db = T2Database(path)
db.memory.search("fts query", project="myproj") # domain method
db.plans.save_plan(query, plan_json) # domain method
db.telemetry.log_relevance(query, ...) # domain methodExisting call sites that use db.search(...), db.save_plan(...),
etc. continue to work via facade delegation -- no migration required.
Every HTTP storage client (T1 HttpScratchStore, the eight T2 Http*Store
classes via RefreshableHttpStoreMixin, T3 HttpVectorClient, and the
catalog client) presents an Authorization: Bearer <token> header on every
call. By default that token is the static service_token credential
(nx config set service_token) or a supervisor-published lease token —
unchanged since RDR-152. When a mint_token credential is configured
(nx config set mint_token, a scope=mint/scope=mint-locked bearer),
nexus.db.data_token.DataTokenManager self-mints a short-TTL scope=data
token per (base_url, tenant) (POST /v1/data-tokens/mint, cached and
refreshed below a 20%-of-TTL threshold or on a 401) and every client
presents THAT instead — a client-held resolution seam, not a per-call-site
change. This is the client half of RDR-005's staged cutover: conexus's edge
today still JIT-injects a shared per-tenant credential and strips whatever
Authorization the client sends (RDR-005 2a's variant-3 posture); once the
edge flips to pure pass-through, a mint_token-configured client presents
its OWN credential's data token end-to-end instead of riding the edge's
injected one. Unconfigured installs (the default, local mode included) see
zero behavior change. A mint failure with mint_token configured never
falls back silently to the static token — it fails loud
(DataTokenMintError), since a half-provisioned install must surface.
mint_token and mint_tenant travel as a pair (nexus-ssqk9). Every
Http*Store defaults its own tenant constructor kwarg to
DEFAULT_TENANT = "default" — but a real scope=mint-locked credential is
bound server-side to whatever tenant the operator issued it under (e.g.
"nexus"), and DataTokenHandler 403s the mint the instant the request
body's tenant field differs from that bound tenant. mint_tenant
(nx config set mint_tenant <tenant> / NX_MINT_TENANT) lets an operator
name the credential's real bound tenant once; DataTokenManager._mint then
sends mint_tenant (when configured) as the mint body's tenant field
INSTEAD OF the caller-passed tenant — every store's own tenant-scoped cache
key and X-Nexus-Tenant header convention are unaffected, only the wire-level
mint body changes. A 403 from a mint-locked credential names both the
configured/requested tenant and the remedy (nx config set mint_tenant <tenant>) in the raised DataTokenMintError, rather than only relaying the
server's own error text. mint_tenant is not itself a secret (a tenant
slug, not a bearer) — it displays unmasked from nx config get/nx config list, unlike every other CREDENTIALS-registry entry.
The mint round trip additionally carries a small bounded retry (max 3
attempts, 1s/2s backoff, honoring a server Retry-After when present) on
the transient gateway/rate-limit statuses {429, 502, 503, 504} —
MintRateLimiter genuinely 429s under load — deliberately never touching
the shared nexus.rate_brake brake (that brake coordinates bulk-write
workers; a mint is a single infrequent auth round trip). nx doctor's
mint_token check routes through DataTokenManager's process-wide singleton
(never a throwaway instance), and its success line reports which of three
things happened (minted a fresh token, reused an in-process cached one, or
reused one borrowed from the cross-process lease file — see below) plus
the granted TTL.
Cross-process lease-file cache (nexus-9c7t9). The in-process cache
above solves residue/rate-limit pressure only WITHIN one long-lived
process (the MCP server); every short-lived nx CLI subprocess used to
start with an empty cache and mint fresh, so five or more back-to-back nx
invocations in one minute exhausted the engine's MintRateLimiter default
burst (5 per credential+tenant per minute) and failed loud. Fixed by
mirroring the lease-file precedent in nexus.db.t1
(publish_t1_session_lease / read_t1_session_lease): every successful
mint also (best-effort) persists the short-TTL DATA TOKEN — never the mint
credential — to ~/.config/nexus/data_token_lease.<key>, where <key> is
a filesystem-safe digest of (base_url host:port, tenant). Mode 0600,
atomic temp-file + os.replace publish. On a genuine in-process cache MISS
(never on a refresh-due-but-still-cached entry), DataTokenManager. bearer_for reads the lease file BEFORE minting, accepting it only when its
format version, tenant, and base-url digest all match AND its remaining
TTL exceeds the same 20% refresh threshold the in-process cache enforces;
any other state (absent, corrupt, foreign, stale) is a clean miss and the
manager mints as before. A lease-write failure is logged as a warning and
NEVER fails the mint — the lease is an optimization, the mint is the
source of truth. invalidate() (the 401 self-heal path) removes the lease
file alongside the in-process entry, best-effort. nx uninstall removes
every data_token_lease.* file unconditionally (not gated on
--remove-data), alongside the managed credentials.
Concurrency is deliberately NOT flock/O_EXCL-guarded: two cold
processes racing to fill an empty/stale cache slot may both mint and both
publish — last writer wins on the file, and the loser's own in-process
token is still perfectly valid, just not the one on disk any more. This is
accepted, not a bug: the race window is bounded to once per TTL-refresh
boundary per (base_url, tenant), and MintRateLimiter's burst=5 absorbs
a handful of concurrent cold starts — a double mint produces two
independently valid tokens, never corruption, so there is no correctness
reason (only an efficiency one) to add cross-process locking here.
Practical effect: a real nx CLI subprocess always has an empty
in-process cache, so it either mints fresh (the very first invocation
after boot, or after the lease has gone stale) or borrows the lease file a
prior invocation published — nx doctor's success line distinguishes
"reused the cached (lease file)" from "reused the cached (in-process)"
(observable only inside one long-lived process, e.g. the MCP server) and
"minted a fresh". Consequences of the residual scope, measured at
nexus-rftfs and narrowed by nexus-9c7t9: the engine still sees roughly one
short-TTL scope=data row per (endpoint, tenant) per TTL window rather
than per invocation (the nexus-lgiqw residue class shrinks accordingly),
and MintRateLimiter's burst ceiling now only binds a genuine COLD-START
STORM (many nx processes launched concurrently before any lease exists)
rather than ordinary sequential CLI usage.
Three incidents (2026-08-19, 2026-08-21 x2 — recorded on the bead) reached
Sam's live production T2/T3/catalog substrate from a dev-checkout process
running OUTSIDE pytest's _pin_t2_substrate autouse fixture: a uv run python -c probe, a deliberate arc-end MVV write, and a scratchpad
verification script that imported tests._catalog_fixture_ops from
outside tests/. None were carelessness — the fixture only defends code
that lives under tests/, and any script, REPL, or one-off invocation
that imports nexus (or a test helper that imports nexus) from anywhere
else silently inherits whatever the ambient config resolves to.
The design (locked by the bead's three recorded incidents): gate WRITES
with an explicit opt-in, fail loud naming the opt-in, never a silent
cwd-based redirect. nexus.db.service_endpoint.guard_production_write is
the one function every HTTP storage client's write path calls:
RefreshableHttpStoreMixin._send for every non-GET verb (the T2 domain
stores and the catalog client, which share this one transport),
http_vector_client._post for T3's write-shaped endpoint suffixes
(store-put, store-delete, update-metadata, upsert-chunks, the
gc/* mutation routes), and — since neither rides the mixin, each being a
bespoke bearer-header-baked client — HttpTokenStore._post and
HttpScratchStore._post (T1) call it directly. Reads are never guarded.
Detection is import-based, never cwd-based. The scratchpad-script
incident's danger came from an IMPORT resolving nexus's package to the
checkout's editable install while the script's OWN cwd was a scratchpad
directory entirely outside the checkout — a cwd check would have missed
it. service_endpoint._dev_checkout_root walks up from
service_endpoint.py's own resolved file looking for an ancestor that is
both a git checkout (.git — a directory for a plain clone, a file for a
worktree) and carries a pyproject.toml naming the conexus project. An
installed generation (<tools>/gen-*/.../site-packages/nexus/...) or a
plain uv tool install copy has no such ancestor at any depth, since
nothing under site-packages ships a pyproject.toml — Sam's real
installed nx never trips this, regardless of env state. Cached per
process (the checkout root cannot change for the life of the
interpreter). Known limit: a copied tree with NEITHER a .git nor a
pyproject.toml ancestor at any depth (e.g. a Docker build stage that
COPYs src/ without git metadata) is not detected as a dev checkout —
undocumented territory this guard does not cover, tracked as a residual
rather than fixed here.
Every dev-checkout write is refused unless the opt-in carries a
reason — there is deliberately no other exemption. An earlier revision
of this guard treated an explicit NX_SERVICE_URL / NX_SERVICE_HOST /
NX_SERVICE_PORT / NX_SERVICE_TOKEN as proof the endpoint was
"pinned," reasoning that a real local supervisor and a test engine both
resolve to http://127.0.0.1:<port> and can't be told apart by string —
so an EXPLICIT env var must mean the caller knows what they're doing.
That heuristic was defeated by conexus's own documented cloud onboarding
(docs/getting-started.md, docs/managed-onboarding.md): both instruct
export NX_SERVICE_URL=https://api.conexus-nexus.com, a permanently
exported shell variable — exactly the shape every one of the three
recorded incidents ran under. An exported env var cannot distinguish "a
fixture pinned an ephemeral endpoint for this one process" from "this
shell always points at the operator's live service," so it is no longer
consulted at all. NX_ALLOW_PROD_WRITE (service_endpoint.PROD_WRITE_OPT_IN_ENV)
must carry an actual reason string — a bare "1" (the retired spelling)
or any other boolean lookalike (0/true/false/yes/no,
case-insensitively) is refused exactly like an unset var
(_OPT_IN_BOOLEAN_LOOKALIKES), so the intent stays greppable rather than
inviting a reflexive =1 export — two of the bead's three recorded
incidents were AI subagents doing exploratory scripting, and a fix-it-
style message ("just set this flag") would have handed them exactly that
reflex. ProductionWriteGuardError's message is a stop-and-verify
prompt: it states the target is presumed to be the operator's real, live
store, that the write must be deliberate and reviewed, and that the
reason must be named in the opt-in itself.
pytest's own suite is exempt via an IN-PROCESS override, never an env
var: tests/conftest.py's autouse _exempt_pytest_from_production_write_guard
fixture calls monkeypatch.setattr(service_endpoint, "_test_only_opt_in_reason", "<reason>") — service_endpoint._opt_in_reason()
checks this module attribute FIRST, before ever reading the real
NX_ALLOW_PROD_WRITE env var. An earlier revision used
monkeypatch.setenv("NX_ALLOW_PROD_WRITE", ...) instead, which DOES
mutate the real process os.environ for the test's duration — any
subprocess a test spawns via env=os.environ.copy() silently inherited
the exemption regardless of whether that subprocess's OWN substrate was
correctly pinned (tests/hooks/test_pre_close_verification_hook.py ::TestF5RemedyRoundTripReal is exactly this shape: it spawns the real
dev-checkout nx as a subprocess to perform a genuine T1 write). The
in-process override cannot leak into a subprocess's environment at all,
so a test spawning a real dev-checkout subprocess that needs the guard's
actual accept path must now forward the REAL NX_ALLOW_PROD_WRITE env
var into that subprocess explicitly, exactly like it already does for
NX_SESSION_ID / NX_T1_ALLOW_SHARED_FALLBACK. Covers the engine-backed
t2_service_env fixture, a fake local HTTPServer
(tests/db/test_refreshable_client.py), and an explicitly pinned
base_url alike, since all three would otherwise be refused. A test that
wants to exercise the guard's OWN refusal logic
(tests/db/test_production_write_guard*.py) resets the override
(monkeypatch.setattr(..., None)) for its own duration — the same
"a later call on the same fixture instance wins" contract as
_isolate_config_dir.
Every ACCEPTED opt-in is logged at WARNING
(guard_production_write.opt_in_accepted, naming the endpoint, the
checkout root, and the reason) — "auditable after the fact" was
previously just a docstring claim with nothing persisted past the
process's own environment. Logged only when the guard actually ENGAGES
(a dev-checkout process); an installed nx never pays for a log line
the guard never needed to consult.
Because many T2 domain stores (and the catalog client, and T1's
HttpScratchStore) send a READ over POST when the query does not fit a
GET query string (search/lookup bodies, batch resolves,
HttpCatalogClient.traverse, operator-query, plan_search,
HttpScratchStore.search/list_entries/flagged_entries/
resolve_prefix_candidates — the last one is the MCP scratch tool's
get/delete disambiguation fallback on an ambiguous or missing id, a
pure lookup that was misclassified as a write for one review round and
turned an ordinary "not found" UX into an uncaught guard error from a
dev checkout), each bespoke/mixin _post carries a mutates: bool = True kwarg — the safe default, since most _post call sites really are
writes. The identified read-shaped POST call sites pass mutates=False
to exempt themselves from the guard; _get and _delete need no such
parameter (GET is always a read, and no DELETE endpoint in this codebase
is a query). T3's _post is a single funnel for both reads and writes
for a different reason (there is no _get/_post split at all —
everything goes over POST), so it keys on the endpoint PATH's suffix
instead (_T3_WRITE_PATH_SUFFIXES) rather than a per-call flag.
Residual, not in this guard's scope: HttpTelemetryStore.record_capability_census/
record_routing_event bypass the guarded transport entirely (a raw
self._client.request(...) call, modeled on the single-attempt shape
query_tier_writes_once — a GET — uses for an unrelated reason). Tracked
and being fixed under nexus-gjv9b, not this bead.
Concurrency Model (RDR-063 Phase 2) — HISTORICAL
This subsection describes the retired SQLite substrate. The per-store
sqlite3.Connections, WAL locks, andbusy_timeouttuning below were deleted with the SQLite stores (RDR-158 P4, nexus-i711w); concurrency is now arbitered by the engine's Postgres. Kept as design heritage for the domain-split shape the HTTP twins inherited.
Phase 2 replaced a single shared connection with per-store connections:
| Phase | Connection | Lock | Cross-domain writes |
|---|---|---|---|
| Phase 1 | one SharedConnection |
one threading.Lock |
serialized in Python |
| Phase 2 | one per store | one threading.Lock per store |
coordinated in SQLite |
Phase 2 consequences:
- Cross-domain reads no longer block on unrelated writes: a
memory_searchon one thread and aplan_saveon another run in parallel because the Phase 1 shared Python mutex is gone. Concurrent writes across domains still serialize at SQLite's single-writer WAL lock. The servingbusy_timeoutis 30000 (RDR-129 B1; the earlier 5000 was falsified under sustained multi-writer load) and the daemon dispatch retries on a transientdatabase is locked(RDR-129 B2), so a contention window past the timeout becomes a wait, not a dropped best-effort write. - Telemetry no longer interferes with search: MCP relevance-log
writes run on the telemetry connection, so
memory_searchis not blocked by access-tracking hooks. - Cluster rebuilds don't freeze memory:
CatalogTaxonomy.discover_topicsruns on the taxonomy connection. The long numpy clustering phase holds no T2 locks, so interactive memory operations continue during the bulk of the rebuild. (The initial embedding-fetch snapshot still briefly acquires the taxonomy connection's lock, as any read does.) - Parallel writes to the same store are serialized by that store's
own
threading.Lockplus the SQLite file-level write lock -- callers never seeOperationalError: database is locked.
This whole section describes a retired mechanism. The T2 daemon, its client, and the
nx daemon t2verb group were deleted by nexus-i711w (RDR-158 P4). The problem it solved — many processes contending on one SQLite WAL writer lock — does not exist against Postgres, which is the write arbiter now. The section is kept because the RDR-129/140/146 sequence is the design heritage behind the current single-writer lease primitive (daemon/service_registry.py), which generalised out of it; read it as how we got here, not as how T2 works today.
The per-store busy_timeout above absorbs within-process cross-domain
contention. Across processes, memory.db had a single owner: the T2
daemon (RDR-120). Other processes reached T2 through it over a local RPC
(nexus.daemon.t2_client.T2Client) rather than opening the WAL writer
lock directly.
RDR-128 enforces that invariant after it had drifted (20+ direct openers
contended on the one WAL writer lock and produced a string of database is locked daemon incidents):
- Routing.
mcp_infra.t2_index_write(write_fn)runs a write through the daemon when reachable (decided by an up-frontdatabase.hello()probe), else a directT2Databasefallback. The hot/automated writers route through it: the indexer (chash + taxonomy persist + aspect enqueue), theaspect_workerpoll (reclaim_stale+claim_batch), the SessionEnd flush, and the routable CLI writers. The daemon RPC wire protocol decodes dataclasses to plain dicts, so methods taking/returning a dataclass the caller introspects (document_aspects.upsert,aspect_queue.claim_batch) either stay direct or reconstruct on the client side. - Enforcement.
nexus.storage_boundary_lint(wired intonx doctor --check-storage-boundary) hard-fails any rawsqlite3.connector directT2Database(...)construction outside its explicit named allowlists. The per-lineepsilon-allowescape token was RETIRED at RDR-186 P4 (census-to-zero): surviving sites — NOsqlite3.connectat all (SQLITE_CONNECT_ALLOWLISTis empty since 2026-08-29: the two frozen-source diagnostics went with their downgrade rationale, because there is no path back to the Chroma/SQLite era) and the documented-irreducible direct constructions (T2DATABASE_CONSTRUCTION_ALLOWLIST) — are enumerated per file with exact counts instorage_boundary_lint.py; a new site is a hard failure, never a comment to write. - Bootstrap serialization.
nx upgradeand the daemon's own startup migration take an exclusivefcntl.flockon~/.config/nexus/t2_migration.lockbefore any schema write, and the startup migration is lock-tolerant (busy_timeout=30000+ bounded retry) so a transient foreign lock waits rather than crashes. - Exactly-one-daemon enforcement (RDR-129). RDR-128 routed writers
through the daemon; RDR-129 hardens the guarantee that there is only
one daemon per
memory.db. On startup the daemon sweeps every live t2 daemon holding the data file open (open-fd probe:/proc/<pid>/fdon Linux,lsofon macOS) and reaps each non-self one, not just the addr-file pid (A1).stop()no longer releases the spawn lock early; the OS drops it on process exit, andensure-runningwaits on the predecessor's PID liveness (not the discovery file) before respawning, so a version cycle converges to exactly one daemon, never zero (A2).nx doctorreports a daemon-multiplicity census as a hard error (A3) and surfaces a dropped-best-effort-write meter as a soft warning (B4); the drop log path is~/.config/nexus/dropped_writes.jsonl(NX_DROPPED_WRITES_LOG_PATHoverride). - Supervisor & ownership model (RDR-140). Where RDR-129 made the
reap unconditional, RDR-140 makes the election single-flight and the
reap ownership-aware, ending the spawn-race / lock-thrash churn under
many concurrent stacks.
ensure-runningtakes a blocking coordination flock around the discover→spawn decision and re-discovers after acquiring it, so K racing stacks converge to exactly one cold spawn with the rest attaching (no thundering herd). A spawn-lock loser quiet-attaches (exit 0, never opensT2Database) instead of crashing. The startup reap spares a healthy, current-version peer named in the addr token (wait-then-force: let a mid-shutdown peer drain, force only if it overstays — never coexist) while still reaping stale-version and unreachable/orphaned writers, so the single-writer backstop is preserved. A bounded crash-loop guard (sentinelt2_crashloop.json) stopsensure-runningrespawns after N failures in a window and surfaces arestarts_in_windowcount innx daemon t2 status. The non-daemon direct-writer fallbacks (thet2_index_writeschema-mismatch arm) remain the RDR-128 A1 boundary, unchanged. - Catalog behind the daemon (RDR-146) — HISTORICAL.
.catalog.db(the 8th T2 domain store, on its own file) was the last shared-state store still on the direct-sqlite3model; GH #1046 was its starvation symptom (an interactivenx dt indexstarved ~30 min by a hook-spawnednx index repoon the shared catalog writer). RDR-146 put the one rich localCatalogbehind the T2 daemon with a write-only op whitelist. The daemon died in nexus-i711w sub-stage B and the local catalog itself in the terminal i711w deletion; what SURVIVES of RDR-146 is its typed factory surface — consumers reach the (now service-backed) catalog throughmake_catalog_reader/make_catalog_writer(HttpCatalogClientunder both), still enforced by the same boundary lint (CATALOG_CONSTRUCTION_BASELINE = 0). The daemon-era fairness protocol described next is retained as history: an interactive write tags its RPC frame (NX_WRITE_PRIORITY/isatty/ per-command intent), opening a short in-memory window the background indexer polls (catalog.is_interactive_write_pending) and yields to over a bounded budget.nx index --on-locked=skipdefers a yielded catalog write to the next idempotent pass; the per-repo advisory lock keeps its orthogonal two-same-repo job.
Migration Registry — DELETED (RDR-076 → RDR-158 P4 Stage 4, nexus-i711w):
the client-side T2 migration chain (src/nexus/db/migrations.py: the
MIGRATIONS / T3_UPGRADES registries, apply_pending,
T2Database.bootstrap_schema, the migration flock) is deleted. Schema is
engine-owned via Liquibase in every mode; any local .db file left over
from the pre-PG era is a relic that nothing reads, migrates, re-stamps, or
probes (RDR-176 Gap 2's
downgrade rationale was retired 2026-08-29 — there is no path back). T2Database.__init__() constructs the
domain stores (all HTTP clients) and runs no schema work; its
run_migrations parameter is retained-and-ignored for signature stability.
Installs still carrying pre-PG local data use the pinned last
migration-capable 6.x release (the two-hop redirect).
Auto-upgrade: nx upgrade --auto runs as the first SessionStart hook,
converging pending ladder rungs and preconditions silently (there are no
local T2 migrations — RDR-158 P4 Stage 4).
See src/nexus/db/t2/__init__.py for the facade source and
tests/test_t2_concurrency.py for the concurrency test suite.
daemon/storage_service_daemon.py's _wait_for_service_ready used a flat
60s timeout for the engine's GET /health to turn 200. The engine runs
Liquibase BEFORE binding HTTP, so /health is unreachable for the entire
migration — a real first-boot migration on a large store runs 20-25 minutes
(rdr180-001 on 107k chunks) — and the old timeout killed the engine
mid-changeset, leaving databasechangeloglock stuck locked=true
(Liquibase's lock row has no session binding to clear it) and looping under
launchd KeepAlive. daemon/readiness.py's ReadinessMonitor (a pure state
machine — clock/log-reader/pg-probe/health-probe/process-poll all injected,
no I/O of its own) fixes the "wait vs kill" decision: a phase's deadline
resets on any progress (a new log line, an admin backend state='active' in
pg_stat_activity, or a /health answer), 60s outside a migration, 600s of
silence inside one, widening to 3600s when Postgres can't be probed. The
supervisor releases a stale databasechangeloglock before every spawn and
after killing a stalled or crashed engine, but ONLY once a liveness gate
clears — no live engine process for this config_dir (process-table scan)
AND no admin backend actively EXECUTING — since a killed supervisor's
engine can survive it (no PR_SET_PDEATHSIG off Linux), and terminating a
still-migrating engine's connections would be worse than the stuck lock it
is meant to fix. A "Waiting for changelog lock" log line alone never
triggers this cleanup (Liquibase gives up on its own after its own 10-minute
wait and exits, which is what actually triggers it); the terminated pids are
logged, not discarded.
| Area | Files | What they do |
|---|---|---|
| Entry | cli.py, commands/ |
Click CLI, one file per command group |
| Command preambles | commands/rdr.py (preamble subgroup), commands/command_context.py, conexus/commands/*.md |
RDR-130: slash-command context preambles. Each of the 25 conexus slash commands injects its preamble via a single-line !`nx <subcommand> -- "$ARGUMENTS"` call — the 9 RDR-lifecycle commands use nx rdr preamble <name>, the 16 agent-relay commands use nx command-context <name>. Preamble logic lives in the tested nx CLI (normal Python, unit-covered) and prints markdown; Claude Code injects that stdout as plain text and does NOT re-parse it, so emitted tables/fences are safe. No command inlines bash, no command depends on $CLAUDE_PLUGIN_ROOT (empty in command-bash context); a static guard (test_migrated_command_uses_single_line_nx) enforces the single-line form across all 25. Replaced the inlined-bash approach whose fenced-block truncation caused the 5.1.2 regression class |
| Catalog | catalog/http_catalog_client.py, catalog/catalog_protocol.py, catalog/factory.py, catalog/types.py, catalog/tumbler.py, catalog/link_generator.py, catalog/auto_linker.py, catalog/store_hook.py, catalog/collection_name.py |
Service-owned document registry + typed link graph (the engine's Postgres tables, reached through HttpCatalogClient via the make_catalog_reader/make_catalog_writer factories — the local JSONL+SQLite catalog was deleted in the nexus-i711w terminal deletion, RDR-158 P4). Tumbler addressing, descendants()/ancestors()/lca() hierarchy helpers, resolve_chunk() ghost element resolution, idempotent link upsert, composable query, bulk ops, audit. Auto-linker creates links from T1 link-context on every store_put. store_hook.py is the shared store_put-origin primitive: catalog_store_hook/catalog_store_hook_tracked register the catalog row at write time, and resolve_knowledge_doc_for_chash (nexus-5axey) is the chash-keyed dedup/delete/reap lookup — content_type == "knowledge" with no file_path, unambiguous match only, ambiguous candidates deliberately left for nx catalog gc rather than guessed. reap_catalog_manifest_for_chashes (nexus-o8dil.5, RDR-191 F10c) is the shared tombstone-before-delete primitive both commands/store.py (nx store delete) and db/http_vector_client.py (expire()) call: ordering is load-bearing, it MUST run BEFORE the T3 chunk delete, never after, because the engine's delete is anti-join-scoped and refuses to remove a chunk any live manifest row still references, including the very document's own not-yet-tombstoned row (see Delete anti-join below). collection_name.py validates conformant collection-name shape (<content_type>__<owner_id>__<embedding_model>__v<n>, RDR-103) at construction time |
| Storage | db/t1.py, db/t2/, db/t3.py, db/http_vector_client.py, db/managed_endpoint.py, db/service_endpoint.py, db/pg_provision.py, db/limits.py, db/local_ef.py, db/inmemory_vector_store.py, db/minilm_direct.py |
Tier implementations. T2 is a package split into domain stores (see § T2 Domain Stores). make_t3() (db/__init__.py) returns HttpVectorClient (T3 over the nexus-service /v1/vectors) by default; db/t3.py is the retired serving path, now chroma-free and kept only as the test facade + ETL wrapper (RDR-155 P4b P3). managed_endpoint.py / service_endpoint.py resolve the service URL/token (T3 reads NX_SERVICE_URL; the T2-stores/catalog resolver uses NX_SERVICE_HOST/PORT); pg_provision.py provisions the local PG17 cluster + writes pg_credentials. limits.py is the single source of truth for size/batch/concurrency ceilings (chroma_quotas.py was DELETED at RDR-155 P4b P3; its QuotaValidator died with no replacement). inmemory_vector_store.py is the dependency-free in-process substrate the tests and the T1 isolated path use; minilm_direct.py is the nexus-owned embedding function that replaced chromadb's (voyage_ef.py was DELETED at nexus-sghyo — the client does no Voyage embedding; the engine embeds server-side). local_ef.py provides the local ONNX embedding function |
| Service stack | daemon/storage_service_daemon.py, daemon/aspect_worker_daemon.py, daemon/binary_install.py, commands/uninstall.py, db/storage_mode.py |
Native nexus-service lifecycle (RDR-155/161): storage_service_daemon.py supervises the PG17+pgvector+service binary; binary_install.py fetches/installs the engine-service binary (PINNED_SERVICE_TAG — DERIVED from REQUIRED_ENGINE_VERSION in engine_version.py, never an independent literal — the single engine identity per release; None pre-6.0). aspect_worker_daemon.py (nx daemon aspect-worker start, RDR-173) is a leased, per-tenant host for the aspect-extraction loop + reclaim_stale, one more tier on the RDR-149 service-registry substrate — spawned automatically (spawn-if-absent, single-flight) by the store_put enqueue hook so extraction no longer depends on the storing process's lifetime. commands/guided_upgrade_cmd.py (nx guided-upgrade) and commands/migrate_cmd.py (nx migrate-to-service) were the RDR-159 provision-then-ETL pair (cross-model mode RDR-162); RDR-185 folded their ETL/verify/report engine into the ladder's substrate rung that nx upgrade walks (see Upgrade ladder below), and RDR-155 P4b then deleted both files outright along with the rest of the Chroma read path — they are not present in this release. A pre-PG install is redirected to the pinned last migration-capable release (nx guided-upgrade there) rather than calling anything in this tree; the true demoted-not-deleted survivors are nx migration, nx collection backfill-hash, and nx hooks update-all (see cli-reference.md § Internal upgrade primitives). uninstall.py (nx uninstall, RDR-165) is the first-class teardown for both local-service and managed-only installs. storage_mode.py routes each T2/T1 store to the service backend, the only backend since RDR-158 — NX_STORAGE_BACKEND=sqlite hard-errors with the stranded-install redirect rather than selecting anything (RDR-152) |
| Upgrade ladder | upgrade_ladder/protocol.py, upgrade_ladder/registry.py, upgrade_ladder/completion.py, upgrade_ladder/runner.py, upgrade_ladder/preconditions.py, upgrade_ladder/census.py, upgrade_ladder/rungs/, commands/upgrade.py |
RDR-185: every DATA transition is a rung on ONE ordered ladder, auto-applied when newer code meets older data — extending the proven T2 apply_pending model to all axes. registry.py holds the walk order with RQ2's hard edges validated as data (chunk-identity and embedder-era are CO-RESIDENT inside the substrate rung — in-flight wire transforms, never sequenced rungs). runner.py walks it under the RDR-142 verify-before-record guard; completion.py is the ladder-local completion store from which the position is DERIVED (max contiguous verified prefix — never stored, no setter). preconditions.py converges the non-data axes (package, engine, process, provisioning) STATELESSLY before the walk: re-derived from on-disk state every invocation (provenance sidecar, lease, package metadata), never recorded — crash-loop-safe by construction. census.py surfaces era debt (pre-RDR-108 chunk ids) from the release that ships the detector, not on migration day. commands/upgrade.py (nx upgrade) is the single trigger; nx doctor reports pending rungs read-only |
| Indexing | indexer.py, code_indexer.py, prose_indexer.py, index_context.py, indexer_utils.py, classifier.py, chunker.py, md_chunker.py, doc_indexer.py, pdf_extractor.py, pdf_chunker.py, bib_enricher.py, languages.py, pipeline_stages.py, checkpoint.py |
Repo indexing pipeline (decomposed per RDR-032). bib_enricher.py queries Semantic Scholar for bibliographic metadata; pdf_extractor.py auto-detects math-heavy PDFs via FormulaItem counting and routes to MinerU (default-installed since nexus-2fyb) for LaTeX extraction; non-math PDFs use Docling. MinerU absence at runtime raises a RuntimeError rather than silently falling back to formula-stripped Docling — the prior silent fallback wiped formulas from every PDF indexed for weeks. MinerU processes large PDFs in 5-page subprocess batches for memory isolation (prevents OOM on formula-dense documents). Chunk metadata includes has_formulas boolean. the three-stage streaming pipeline (RDR-048) buffers through the engine's nexus.pdf_pipeline/pdf_pages/pdf_chunks tables via db/http_pipeline_client.py (RDR-186 retired the local pipeline.db SQLite buffer); pipeline_stages.py implements the concurrent extractor/chunker/uploader stages and orchestrator; checkpoint.py handles batch-path crash recovery for smaller documents (RDR-047) |
| Export | exporter.py |
Collection export/import for T3 backup and migration (.nxexp format) |
| DEVONthink | devonthink.py, commands/dt.py |
macOS-only nx dt integration verbs (RDR-099). devonthink.py exposes 5 selector helpers (_dt_selection, _dt_uuid_record, _dt_tag_records, _dt_group_records, _dt_smart_group_records) over a centralised _run_osascript spawn; the smart-group helper does an sdef-canonical three-property read (search predicates PLURAL + search group + exclude subgroups) and re-executes the search to honour user-authored scope. commands/dt.py is the Click surface: nx dt index dispatches per-record by extension (.pdf/.md) into the existing nexus.doc_indexer entry points, and nx dt open round-trips tumblers/UUIDs back to DT via open(1). Substrate meta.devonthink_uri reverse-lookup shipped in 4.17.0 (nexus-srck) |
| Plans | plans/matcher.py, plans/runner.py, plans/bundle.py, plans/session_cache.py, plans/loader.py, plans/match.py, plans/scope.py, plans/schema.py, plans/seed_loader.py, plans/promote.py, plans/purposes.py |
Plan-centric retrieval stack. matcher.py: T1 cosine + T2 FTS5 fallback with RDR-091 scope filter/re-rank. runner.py: plan_run executes step DAGs — contiguous operator runs collapse into a single claude -p call via the bundle path (v4.10.0). bundle.py: operator-bundle module — segmentation, composite-prompt composition with source attribution + deferred-ref rendering, single-dispatch execution, 200k-char size guard with per-step fallback. session_cache.py: plans__session T1 cosine cache (MiniLM). loader.py + seed_loader.py: YAML plan loading + seeding of builtin templates. match.py: Match dataclass contract. scope.py: scope normalization + scope-fit weight. schema.py: step schema validation. promote.py: plan promotion heuristics. purposes.py: typed-link purpose registry for traverse operator |
| Console | console/ (app.py, watchers.py, config.py, routes/), commands/console.py |
Embedded web UI for monitoring agentic Nexus activity (nx console). FastAPI/uvicorn server with live-updating routes for activity, campaigns, health, and partials. commands/console.py handles start/stop lifecycle and PID file management |
| Search | search_engine.py, search_clusterer.py, scoring.py, frecency.py, ripgrep_cache.py, filters.py |
Query, rank, rerank. scoring.py applies topic boost (apply_topic_boost: same-topic -0.1, linked-topic -0.05). search_engine.py does topic grouping (T2 assignments when >50% coverage) with fallback to Ward hierarchical clustering. filters.py also contains sanitize_query() (RDR-071) which strips LLM prompt contamination from search queries before embedding |
| Context | context.py, commands/context_cmd.py |
L1 project context cache (RDR-072). generate_context_l1() builds a ~200 token topic map from taxonomy, cached as flat file at ~/.config/nexus/context/<repo>-<hash>.txt. Injected by SessionStart hook for agent cold-start acceleration. Auto-refreshed after taxonomy discover and index repo |
| Taxonomy | db/t2/catalog_taxonomy.py, commands/taxonomy_cmd.py, taxonomy.py (shim) |
HDBSCAN topic discovery from T3 embeddings (RDR-070). T2 tables: topics, topic_assignments, taxonomy_meta, topic_links. Centroids on pgvector (nexus.taxonomy_centroids, unified single table since RDR-191 Phase 4) via nexus-service (HttpCentroidStore) for centroid ANN, since RDR-155 P4a.2. discover_for_collection() is the shared entry point for CLI and nx index repo. taxonomy_assign_hook in mcp_infra.py fires on every store_put for incremental assignment. taxonomy.py is a backward-compatibility shim that forwards old call sites to db.taxonomy |
| Hooks | commands/hooks.py, commands/hook.py |
hooks.py: Git hook install/uninstall/status, sentinel-bounded stanza management. hook.py: Claude Code SessionStart/SessionEnd lifecycle runners |
| Verification | config.py (verification section), conexus/hooks/scripts/stop_verification_hook.sh, conexus/hooks/scripts/pre_close_verification_hook.sh, conexus/hooks/scripts/read_verification_config.py |
Opt-in mechanical enforcement: Stop hook (session-end checks), PreToolUse hook (bd-close gate), standalone config reader. See Verification config |
| MCP Servers | mcp/core.py, mcp/catalog.py, mcp_infra.py, mcp_server.py (shim) |
Multi-server FastMCP architecture (RDR-062, RDR-139). nexus core server (38 tools: storage, retrieval, operators, orchestration) + nexus-catalog (10 tools: catalog and link graph). (The RDR-139 Layer A' nx-mcp-devonthink proxy was retired 2026-07-07, nexus-goypg — clients connect to DEVONthink's own MCP server directly; its dt_incorporate composite lives on as nx dt incorporate.) Short-name convention: catalog tools drop the redundant catalog_ prefix since the server namespace already provides context. Six destructive / maintenance operations are intentionally kept CLI-only. Backward-compat shim at mcp_server.py re-exports every function. query() has catalog-aware routing (author, content_type, subtree, follow_links, depth); singletons and test injection live in mcp_infra.py. For the full tool catalog see MCP Servers. |
| Enrichment | bib_enricher.py, aspect_extractor.py, aspect_worker.py, commands/enrich.py |
Two enrichment surfaces. (1) Bibliographic via Semantic Scholar (bib_enricher.py lookup + nx enrich bib CLI). (2) Structured aspects via Claude CLI (aspect_extractor.py synchronous extractor + aspect_worker.py async-queue daemon worker registered as the document-grain post-store hook + nx enrich aspects CLI). Aspect extraction is knowledge__* only in Phase 1 (RDR-089); the worker drains aspect_extraction_queue and writes to document_aspects |
| Health | health.py, logging_setup.py |
health.py: health check data model and runner used by nx doctor and nx console. logging_setup.py: structured logging configuration for CLI, console, MCP, and hook entry points (stderr + rotating file handler) |
| Support | config.py, registry.py, corpus.py, session.py, hooks.py, ttl.py, formatters.py, types.py, errors.py, retry.py, commands/_helpers.py |
Configuration, naming, formatting, session lifecycle, transient-error retry. _helpers.py: shared CLI helpers (e.g. default_db_path()). (_provision.py — ChromaDB Cloud database provisioning — was DELETED at RDR-155 P4b P2; it had zero src callers.) |
The plan-centric retrieval stack ships twelve builtin templates under conexus/plans/builtin/. The seed loader (nexus.plans.seed_loader.load_seed_directory) upserts them into PlanLibrary on first run; idempotent thereafter. Each template pins a verb dimension (and usually scope: global); the matcher uses verb to filter candidates before cosine ranking.
Grouped by verb:
- verb=query
abstract-themes: CheapRAG community-summary pipeline (search→groupby→aggregate→summarize) for theme extraction, topic landscape, and summary-of-findings questions. RDR-098.
- verb=analyze
analyze-default: Cross-corpus synthesis across prose and code. Gathers from both sides, walks reference chains, hydrates candidates, ranks against the caller's intent.
- verb=research
research-default: Concept → prose → implementing code. Walks from RDRs/docs/knowledge into the modules that implement them, then surfaces concrete code context.citation-traversal: Trace the citation chain around a seed document. Walkscitesedges inward and outward, hydrates matches, summarises.find-by-author: Author-index lookup. Routes through the catalog's author index, hydrates matching documents, summarises contributions.type-scoped-search: Single-content-type semantic search. Resolves the content-type bucket and runs the query against only those collections.
- verb=lookup
hybrid-factual-lookup: Factual claim, named entity, or specific data point. Fuses vector recall with FTS lexical match for narrow-target retrieval.
- verb=document
document-default: Documentation authoring or audit. Gathers prose and code touching the area, walks documentation-for edges, hydrates both corpora.
- verb=review
review-default: Change-set critique. Resolves changed files to catalog entries, walks decision-evolution history (RDRs superseded or cited), hydrates the RDR context.
- verb=debug
debug-default: Dev work from a concrete failure. Catalog per-file lookup as the primary link walk; multi-hop graph traversal is delegated to Serena.
The plan-author / plan-inspect / plan-promote templates and their skills were RETIRED at nexus-77cct. They dispatched a plan_match MCP tool that has never existed (the server registers plan_save, plan_search and plan_delete only), so nothing ever invoked them successfully — and they were not inert, since their descriptions absorbed any question containing the word "plan" and outranked the plan a caller actually wanted. What they described is nx plan list / nx plan show / nx plan hygiene, which work. traverse-then-generate was retired in the same change: it required caller-supplied catalog tumblers, which no question carries.
Every shipped template must be offerable — reachable by some question. A template requiring a typed binding that is neither defaulted nor derivable from a question (nexus.plans.binding_infer) fails CI in tests/test_builtin_plans.py.
-
Protocols over ABCs --
typing.Protocolfor structural subtyping, no inheritance coupling. -
No ORM client-side -- Python T2/T3/catalog clients speak HTTP to the engine, never SQL; the engine owns schema via Liquibase-managed Postgres (with a jOOQ-generated codegen layer server-side,
service/pom.xml). The historical direct-sqlite3T2 (WAL + FTS5, stdlib) was the migration source, retired at RDR-158 P4. -
Constructor injection -- Dependencies via constructor, no global singletons.
-
Ported, not imported -- SeaGOAT and Arcaneum patterns rewritten in Nexus module structure.
-
Session-id-scoped T1, service-backed -- Historically (RDR-149 P4) the MCP server's chroma lifespan started a per-session ChromaDB HTTP server and published a leased registry record at
~/.config/nexus/t1_addr.<session_id>; that discovery mechanism retired with the chroma substrate (RDR-155 P4b). Todayget_t1_databaseroutes T1 toHttpScratchStoreover the onenexus-service, scoped by the same Claude session-id (resolved from~/.config/nexus/current_session) — child agents and Bash-tool siblings resolve the same session-id and share T1 scratch across the agent tree; concurrent independent windows stay isolated via distinct session-ids. The in-processNX_T1_ISOLATED=1opt-out that survived that retirement is itself retired (nexus-4lkmz, Hal determination 2026-07-28: "T1 exists in PG only") — setting it now hard-fails withT1IsolatedLegRetiredErrorinstead of opting into a private in-processInMemoryVectorClient; a process outside service-mode routing raisesT1ServerNotFoundErrorrather than inventing a private store. -
MCP tools over agent-spawns for utility operations (RDR-080) -- Operations that formerly required spawning a named agent are now MCP tools that execute in-process. Agent files are retained as stubs that redirect to the MCP tool.
Boundary rule: If an operation can be expressed as a deterministic function of its inputs and completes in under one API call, it is an MCP tool. If it requires multi-turn reasoning, tool selection, or context accumulation across turns, it is an agent.
Capability Before RDR-080 After RDR-080 Knowledge consolidation knowledge-tidieragentmcp__plugin_conexus_nexus__nx_tidyPlan audit plan-auditoragentmcp__plugin_conexus_nexus__nx_plan_auditBead enrichment plan-enricheragentmcp__plugin_conexus_nexus__nx_enrich_beadsMulti-step retrieval query-planner+analytical-operatoragentsmcp__plugin_conexus_nexus__nx_answerPDF indexing pdf-chromadb-processoragentnx index pdfCLI / direct ingestWhen authoring agent/skill instructions, always use the full MCP tool name (
mcp__plugin_conexus_nexus__<tool>) — short names fail at runtime.See MCP Tools vs Agents for the full boundary rule, the stub-agent pattern, and guidance on where to place new capabilities. See Plan-Centric Retrieval for how
nx_answer+ the plan library replaced the earlier retrieval-agent chain.
Rule of record, full text, mechanism and measured history: JDR-001
(docs/rdr/joint/JDR-001-t1-three-scopes.md). In brief: T1 is one
PG-backed store reached through three scopes that exist at once. MCP-tool
T1 is scoped to the session id leased at MCP-server spawn and moves only
when the SessionStart hook's /clear or /resume handoff marker is
consumed by the MCP lifespan's watcher (nexus-d76vc, one poll tick, old
rows strand rather than migrate). nx CLI T1 follows the current
transcript session's live lease, fails loud on an explicit session id
with no lease (nexus-f7xyq), and falls through to a shared CLI identity
only on a bare invocation. ~/.config/nexus/current_session is a
machine-wide last-writer-wins file, never a per-conversation value.
Consequences: review-completed markers go through the CLI, durable
write-back goes to T2, and an agent's write-back is declared lost only
after the agent has terminated.
| Tool | What Nexus borrows |
|---|---|
| mgrep | UX patterns, citation format, Claude Code integration |
| SeaGOAT | Git frecency scoring, hybrid search, persistent server |
| Arcaneum | PDF extraction + chunking pipelines, RDR process |
The storage stack (Postgres 17 + pgvector behind the native nexus-service, with server-side bge-768 / Voyage embedding) and the indexing layers are Nexus's own.