Repository navigation
v7.85.0
Pairs with engine-service-v0.1.164 (REQUIRED_ENGINE_VERSION 0.1.163 to 0.1.164), deployed to the managed service before this tag (2026-10-23 10:32Z; every wire entry is additive). A local install's first start on this engine applies three changesets: vectors-037-1 and vectors-037-2 (the reembed_jobs and reembed_job_moves tables of the RDR-210 re-embed job, FORCE RLS) and memory-005-1 (a partial index on nexus.memory, the table not rewritten). No data-effecting changeset.
Added
nx doctorhas a "Re-embed jobs" row, and the "Collections off-profile" row names the right remedy and says what it could not judge (nexus-s08oe, RDR-210 Phase 6 Step 5; client half, the engine tag andREQUIRED_ENGINE_VERSIONare paired at the release). The new row reads the job table through the status route in local mode only. It is not applicable and green on a box that is not in local mode, when the table is empty or unreadable, and when nothing in it is running or failed; it is green with counts by state forrunning,planned,cutoverandpausedjobs; it is a soft warning for afailedjob, naming its reason, its last error and the command that moves it on (--resume JOB;--resume JOB --token-cap Naftertoken_cap;--abort JOB, then--all, afterprofile_changed, which a resume would only repeat), and for a job parked in a blockedcutover, which is offered only--abort(the engine has no resume out of cutover, and refuses the abort too once the delete step has started, which the row then says). The off-profile row now suggestsnx collection re-embed --allin local mode (nx collection reindex <name>elsewhere, where the job does not run), and akept_tombstonedsource with a live document restored into it is named as a superseded source thatre-embed --allmoves, not as a collection whose reads route by its own model. When the job table or the document counts cannot be read, the row sayssuperseded sources not judged (job table unread)or(document counts unread), whichever failed, instead of reading a cleannoneover the superseded rows it skipped. A stale collection a running or failed job already holds is named with that job and the row points at--status, since--allnames the job and plans nothing for it. One difference from the RDR's Day 2 table stays: afterpurge-trashon akept_tombstonedsource the doctor is clear while--allstill lists a delete-only cutover re-entry.nx collection re-embed --all --skip NAMEleaves a collection out of the run (nexus-kyzmw).--allplans every stale collection and starts none when any plan is refused, so one collection refused for good (a held target name, a target collision) made--allunusable.--skipis repeatable and goes with--allonly; a skipped collection is not planned and is listed asNAME: skipped (--skip), and a NAME that is not one of--all's candidates (stale, held by a job, or a re-entry) is an error that names them. Without--skipthe run is still all-or-nothing.nx collection re-embedruns the cross-model re-embed job:--all, a cross-model--to,--keep-source,--force-delete,--token-cap, and--status,--pause,--resume,--abort(nexus-cnxjk, RDR-210 Phase 6 Step 3; local engine only, refused in any other mode).--allplans every live collection whose model differs from the engine's profile for its content type;NAME --to MODELwith a model other than the collection's own does the same for one collection, and MODEL must be the profile's (bge-base-en-v15-768is now a choice). Dry run stays the default and states the documents and chunks that move, the token estimate against the cap (10,000,000 for a Voyage target, none for the on-device bge model), and the counts the source's delete would destroy: quarantine rows, topic assignments of other collections and topic links that hang off its topics, and satellite rows naming it whose document does not move. Every collection is planned before any is started, and a plan above the cap, a target model the engine cannot serve (/versionembedding_models; the refusal names the missing key or bge files and the restart, nexus-q6t89), or any other refusal starts none. A source another job holds is named, not planned again;--alllists akept_tombstonedsource as a re-entry once its tombstones are gone, and does not start it until the engine's re-entry route lands (nexus-09iho), and never lists akept_by_flagorkept_residuesource.--waitis not implemented yet. Engine half: the/v1/vectors/reembedplan and status routes (below); rides the next engine tag.- A re-embed start with no
token_capis capped at 10,000,000 tokens when its target is a Voyage model, and the plan reports the cap and the live documents homed at the source whose chunks sit elsewhere (nexus-8zzjh, nexus-bi56e; rides the next engine tag, local engine only). RDR-210 Cost says each run has a cap of 10 million by default; until now only thenxclient sent one, so a start made with curl or another client on a billed target ran uncapped. The engine now applies the default when the request sends none, unless the target model is a local one (bge, MiniLM), which stays uncapped; a model of any other family is treated as billed. A value sent is kept as sent, higher or lower, and a start whose plan's token estimate is above the cap is refused with a 409 before anything is written.POST /plangainstoken_cap(what a start with no cap would apply, null for a local target) andlive_homed_elsewhere, a count of the residue the cutover already held the delete for (a live document homed at the source whose manifest names only other collections), so a source can no longer plan clean and then stall incutover.nx collection re-embedprints that count in place of the line that said the plan did not predict it. - The engine serves
POST /v1/vectors/reembed/planandGET /v1/vectors/reembed/status(nexus-cnxjk; rides the next engine tag, local engine only). The plan repeats every refusal a start makes, writes nothing, and counts what the source's delete would remove; the status lists every job row of the tenant, newest first.HttpVectorClientgainsreembed_plan,reembed_start,reembed_status,reembed_pause,reembed_resume,reembed_abortandserved_embedding_models, each sending exactly the fields its route takes (the engine refuses an unknown one). All four route groups are[additive]entries indocs/wire-contract-pending.md. nx config set/nx config unsetandnx daemon service startnamenx collection re-embed --allwhen collections are on the previous embedding model (nexus-cnxjk, RDR-210 Phase 6 Step 3). Setting or unsettinglocal.embed_modelorvoyage_api_keyprints the line after the restart line when the change flips the embedding profile (a Voyage key set beside a recorded bge choice does not).nx daemon service startprints it when a live collection is off the engine's profile, read once from the catalog with a 5 second bound: after the running line in the plain output, on stderr under--announce-stdout(stdout stays one JSON object), and from the supervisor under--foreground.- A re-embed job ends by what its source still holds: deleted,
kept_tombstoned,kept_by_flag,kept_residue, or failed withparity_mismatch(nexus-09iho, nexus-6grho, RDR-210 Phase 6 Step 4; engine change, rides engine tagengine-service-v0.1.164, local engine only). After the pass the cutover looks for a live document or a manifest row still naming the source before it supersedes, then, in the RDR's order: a soft-deleted document still in the source ends the jobdone/kept_tombstoned(source superseded and kept, no parity check);--keep-sourceendskept_by_flag; the parity check fails the jobfailed/parity_mismatchwith the counts and the documents to inspect inlast_errorwhen a manifest row still names the source, a live document is homed there, or a moved document is not on the target by both columns, and deletes nothing (--resumere-runs the pass and the check and fails the same way while the cause stands,--abortleaves the source superseded and undeleted); aspect, queue or highlight rows naming the source that match no moved document enddone/kept_residueunless the job was started with--force-delete, which now lifts that refusal (nothing read it before) and deletes them with the source.--force-deletedoes not reach the parity check: no flag deletes a live document's only copy. A live document that arrives in a source the job has already superseded (after a parity failure and a resume, or a re-entry) sends the job back torunningto move it, instead of endingdone_with_skipswith the document stranded in a source nothing routes to; only a document the job already tried and could not move endsdone_with_skips, and the job says where it is. The parity check and the trash count run again immediately before the delete, past thedelete_startedmarker too, so a document registered at the source since (the engine refuses chunk and manifest writes there but not a bare registration) fails the jobparity_mismatchinstead of dying with the source. The--force-deletenote is written with the marker and the delete's removed counts after it, so a restart keeps them. This replaces Step 2's interim behaviour of waiting incutoverwith a blocked message. - A
kept_tombstonedjob re-enters through the start route, andnx collection re-embed --all --no-dry-runstarts the re-entries it lists (nexus-09iho; rides engine tagengine-service-v0.1.164). A start whose source is the kept source of adone/kept_tombstonedjob takes that job back torunning(a document was restored) orcutover(the trash was purged) with its own target, cap and flags; the body is the source alone. Until now--alllisted these sources and could not start them. nx collection re-embed --waitfollows the jobs it started (or--resume JOB) todone,done_with_skips,aborted,failedorpaused, names each state, and runs taxonomy discover for the target of the first two (nexus-09iho, RDR-210 Phase 6 Step 4). It exits 1 when a job stopped short (aborted,failed,paused, or parked in a blockedcutover, which it reports with the job's last error instead of polling forever) or when the discover failed. A re-entry takes the same rename and cross-job guards as a start. Without--wait, the start and--statusnamenx taxonomy discover --collection TARGETfor a finished job: until it has run, topic-scoped search and topic boosts miss the moved documents.nx collection re-embed --statusshows what a job's source delete removes: quarantine rows, topic assignments of other collections, topic links, and unmatched satellite rows (nexus-09iho;GET /v1/vectors/reembed/status?counts=true, rides engine tagengine-service-v0.1.164). The counts are read while the delete is still ahead of the job, or the source was kept, and are the ones the delete removes.
Changed
nx t3 gcno longer probes for quarantine siblings, and a first expire page that names none is an exit 1 (nexus-6bk7i, follow-up to nexus-wbfpw.64). The dry-runquarantine-restoreprobe was kept only for an engine from beforeengine-service-v0.1.163, which is now the oldest engine the client supports (REQUIRED_ENGINE_VERSION), so the probe path,resolve_quarantine_siblings(probe_engine=)andsiblings_after_first_page(probe_fallback=)are gone. A first page that answers neitherquarantine_collectionsnorsibling_resolutioncomes from an engine below that floor (a managed endpoint below it is refused before any request, but a local engine not yet restarted after a client upgrade still answers) or from a defect: bothnx t3 gcandnx index repogo on with the row-derived name andquarantine-<collection>, the index path logsquarantine_sibling_resolution_unanswered, andnx t3 gcexpires from those two, then exits 1 saying the siblings were not named and namingnx doctorandnx daemon restart-stale. A first page that committed with its answer lost is now also an exit 1 after the same expiry; it probed and exited 0 before (re-run to ask again). Fewer siblings are visited then, never more, so nothing is expired that a resolved list would not also reach. A timed-out lookup is unchanged and still exits 0: the engine did answer, and the verb says another sibling may wait for the next run. The engine'squarantine-restoreroute and itsdry_runfield stay (nx t3 quarantine restore --dry-run).docs/operations/engine-reaper.mdanddocs/cli-reference.mdsay the same.nx doctor's "Collections off-profile" row, the re-embed hint andre-embed --allshare one off-profile rule, so the row and the hint clear afterre-embed --keep-source(nexus-n6mh0, RDR-210 Phase 6 Step 5). The doctor counted every live collection on a model other than the profile's, including a superseded source--keep-sourcekept, so its row stayed lit after the documents had moved and the hint (which already left superseded rows out) disagreed with it. Both now readreembed_job.off_profile_rows, which--allshares throughcandidates(): a live, unsuperseded collection on another model is off-profile, and a superseded one is not, with the RDR's one exception, a source akept_tombstonedjob left superseded that holds a live document again (a restore; nothing serves it until--allmoves it). Akept_by_flagorkept_residuesource, akept_tombstonedsource whose documents are only tombstones, and a rename's tombstone no longer count. One deliberate difference remains: akept_tombstonedsource with no document row at all (purged) is still listed by--allas a delete-only cutover re-entry, while the row and the hint stay clear, since nothing in it is stranded. The job table is read only when some collection is superseded; if it cannot be read, the superseded rows are left out and the rest still count. The row's remedy is nowre-embed --allwhere it applies (nexus-s08oe, below).--lexicalsearch runs its lexical leg on/hybrid-searchagain; the per-collection route'slexicalfield is sent only withNX_SEARCH_ROUTE_LEXICAL=1(nexus-n51v1). With 7.84.0 and engine-service-v0.1.163, the field made the engine run one lexical statement per collection, 0.3 to 0.65 s each, and a--lexicalsearch over 118 collections took a median of 11.99 s, against 6.90 s for the batched/hybrid-searchleg (nexus-pq3wr, measured live 2026-10-22). The default gives back what the field bought: the leg is again one/hybrid-searchcall per batch of collections, and a collection with many text matches can again crowd its batch-mates' lexical rows out. A search without--lexicalis unchanged.NX_SEARCH_ROUTE_LEXICAL(1,true,on,yes) sends the field, as 7.84.0 did.- The engine's RDR-225 lock preflight counts the lock table Postgres guarantees (every backend slot), not
max_connectionsalone (nexus-xzit5, rides the engine tag after engine-service-v0.1.163). At the defaultmax_locks_per_transactionof 64 the walk is refused above about 48 tenants instead of 39, and at the managed 128 above about 99 instead of 81.
Fixed
- The re-embed plan and start refuse, naming the missing piece, when the engine cannot embed for the target model (nexus-q6t89, RDR-210 Phase 6 Step 3; engine change, rides engine tag
engine-service-v0.1.164, local engine only). A degraded boot serves one family (avoyageprofile with no key, abgeprofile with no model files), and a job started into the other family registered an empty target, then failed at its first batch.POST /v1/vectors/reembed/planand/jobsnow read the models the embedder router serves (the listGET /versionreports asembedding_models) before the target is looked at, and answer 409 with the same sentence the client's/versioncheck prints (the missing Voyage key or bge files, and the restart), before anything is registered. A router that cannot answer is refused, not read as serving everything. A job that later fails its first batch for another reason still leaves its empty registered target, as before;--abortor--resumehandles it. The client's check was already in place (nexus-cnxjk). - A rejected T1 session token is remembered across the process, so
tuple_session_header_droppedlogs once, not on every channel-waiter tick (nexus-z1xox, follow-up to nexus-ztwp4). 7.84.0 kept the rejected-token memo on theHttpTupleStoreinstance, but the channel waiter and every MCP tool open a freshT2Database(hence a fresh store) per call, so the memo never survived one: a server whoseNX_T1_SESSIONthe engine rejects re-sent the header, paid the 401-then-retry round trip and logged at INFO on every ~38 s waiter tick (261 times in one afternoon). The memo is now process-wide, scoped by endpoint and data token so one tenant's rejection never suppresses another tenant's valid token, and it lapses after 5 minutes, when the token is offered once more. It holds hashes only, never a token, and a changedNX_T1_SESSIONis sent at once. The event also carriespid, so a log line names the process behind it. - A collection rename refuses a conformant new name whose model token disagrees with the source collection's catalog row, and the indexer keeps a legacy collection whose row model is not the write model (nexus-7f4o4). The engine rename keeps the source row's
embedding_model, sonx collection renameandnx catalog rename-collectioncould leave a name claiming a model the vectors were not in:nx collection renamecompared model tokens derived from the two names, so a legacy bge-row collection renamed to avoyage-context-3token passed and the correct bge rename was the one refused, andnx catalog rename-collectionchecked only that the new name is conformant. Both now compare the new token with the row's model and refuse a mismatch, naming both models and the name that would be accepted.nx catalog rename-collectionreads the model from the engine row it has already fetched, not from the stats-view cache, so a failed listing cannot let a mismatch through; with no row, or a row with no model, its fallback reads only a conformant token's own model, so a legacy name still renames as before.nx collection renamefalls back to the names' derived models when no row backs the old name, as before. Adisputedsource (its bytes disagree with its row) is not compared and prints a warning on both verbs.nx collection rename --force-prefix-changegets past the guard on that verb; it renames and never changes the row, so it is for a rename the operator knows is right.nx catalog rename-collectionhas no override of its own. The first-index Phase-4 migration is unchanged when the legacy row's model is the model the indexer writes (or no row backs it). When the row names a different model it no longer renames: the legacy collection stays the indexing target on every run, and each run logsphase4_migration_row_model_differs_from_write_modelnaming both models; the way to move it is a re-embed to the write model. Renaming it to the write model's token would claim vectors it does not hold, and renaming it to the row's token would strand it, because later runs mint from the write model. The row is read from the catalog the migration already holds, and a failed read keeps the legacy name for that run rather than renaming blind. Only the rename case is guarded: a legacy collection beside an existing write-model sibling still resolves to the sibling, as before.nx collection renamestill reads the row from the stats-view cache, so a failed listing there falls back to the name comparison it used before. Client only. - A session's name stays in the peer directory across a restart,
--resumeor/clear, somailbox_send(to=<name>)resolves without the model re-subscribing (nexus-u839f). A name was held only after the model calledtuple_subscribe("mailbox/<name>"), and nothing re-armed it, so after a coordinated restart every name answered "no live holder" until each session happened to subscribe again. The MCP server now reads the name from Claude Code's own session registry (<config>/sessions/*.json, the live interactive entry whosesessionIdis this server's session id, newestupdatedAtfirst) when it starts its channel waiter: at server start, after a deferred T1 mint and after a/clearhandoff for the new id. It leasesdirectory/<name>through the ordinary lease. The registry read is local file I/O on the calling thread; the first engine write is the lease thread's first tick, so a down engine is that thread's 5-second retry and never a stall of the server's event loop. The lease thread then re-reads the registry at most once every 60 seconds, and swaps the row (releases the old name, arms the new) when the name changed, so a/rename, a/clearwhose registry update lagged, or a startup miss is picked up without a second thread. A miss, or a name that is not a safe address name (one with a space, say), keeps what is held. A miss on a registry directory that exists logs one INFO line,session_name_unresolved(session id, directory, number of files read; never their contents), which is also how a Claude Code format change shows; a missing directory logs nothing, and an unsafe name logs at DEBUG. An explicittuple_subscribeof the same name changes nothing (the SessionStart line asks every session to make that call, and the registry is still followed); of another name it replaces the registry-derived one; an explicit name is never replaced by the registry. A name another live session still holds is refused at send time as before. Hosts without Claude Code's registry still name themselves withtuple_subscribe. - A stored note is split to the token window of the model its catalog row names, not the model its collection name derives (nexus-z8mi2, RDR-210 follow-up to nexus-noayy and nexus-ml9vu).
store_put,nx store put,nx memory promoteand the recovery import chose the split window from the collection name. A legacy name carries no model token, so on a bge-profile engine a collection whose row saysvoyage-context-3was cut to bge's 512-token window instead of Voyage's none. The row'sembedding_modelnow decides, the name only when no row backs it or the row has no model; that covers a conformant name too, whose token a rename can leave disagreeing with the row the engine embeds by. The row comes from the shared collections cache, which a cold or stale cache fills with one catalog-only listing, so a freshnx store putpays one listing request and a warm cache pays none. MCPstore_putempties that cache after each committed note, so in a run of consecutivestore_putcalls every note after the first pays one listing. A listing that cannot be fetched also falls to the name. - A stored note's chunk metadata stamps the embedding model its catalog row names (nexus-ldh33, follow-up to nexus-z8mi2). Each chunk's
embedding_modelwas derived from the collection name, so a legacy collection whose row says bge was stampedvoyage-context-3while the engine embedded it with bge. The stamp now comes from the same row read that picks the split window, at no extra request; with no row it follows the install's profile for a legacy name, as the window does. The engine embeds and searches by the row and never reads this field itself (a caller'swherefilter can still name it). Its only internal reader is the indexers' unchanged-content skip, which a note chunk reaches only when a file's hash equals the chunk's;nx doctortakes the model from the collection name, not the chunks. Existing chunks are left as stamped. nx index mdandnx index pdf(andnx collection reindexandnx dt, which call them) stamp, compare and split by the embedding model the collection's catalog row names (nexus-7jy5r, sibling of nexus-ldh33). Both took the model from the collection name, which guesses Voyage for a legacy name and keeps the source's token after a rename, so a collection whose row says bge got bge vectors stampedvoyage-context-3, and the unchanged-content skip compared against that guess. The model now comes from the row, read through the indexer's own store (its tenant, the shared cache for the process's default client), with the name as the fallback. A store without a catalog (an in-memory double, a dry run's throwaway store) and local mode's own embedder are unchanged. A file whose chunks carry the old guess is re-indexed once on its next run, to the row's model. The same model picks the token window chunks are split to, so in a legacy collection whose row names bge that re-index also re-splits the file to bge's 512-token window (the Voyage guess left it unsplit, and bge truncated the long chunks). A non-default-tenant client now reads the row from the routing listing, which reads the catalog alone, instead of the full listing that counts every collection's chunks.nx index repois unchanged: its names are conformant and it reads the token by design.- A collection-row read that fails no longer makes
nx index md/pdfre-index unchanged files (nexus-s8r5o, follow-up to nexus-7jy5r). When the catalog listing could not be read, the indexer took the name's model as the target and the unchanged-content skip compared stored stamps against it, so a legacy or renamed collection whose row differs from the name re-indexed its files during the outage, re-stamped them with the guess, and re-indexed them again once the listing was back. The indexer now asks for the row strictly (mcp_infra.get_collection_row(strict=True),HttpVectorClient._resolve_collection_row(strict=True)), and when it cannot be read the skip judges by content hash alone and logsindex_target_model_row_unreadable; a changed file is still written, with the name's model, and re-indexed once to the row's model on the next run that can read it. The strict read covers a client for a non-default tenant too. Every other caller keeps the non-strict read. nx_answerand the otherclaude -poperators work on Claude Desktop after a plugin update (nexus-x6qc7, shakeout F19). Desktop starts its MCP servers in the plugin's install directory and deletes that directory when it updates the plugin, while the servers keep running there. Everyclaude -pthe server spawned then inherited the deleted directory and exited 1 ("The current working directory was deleted"). The operator spawn now names its working directory: the caller's while it exists (a Claude Code server's project directory, as before), otherwise the filesystem root (the nexus-x3m8e stable cwd); an operator that reads project files (nx_enrich_beads,nx_plan_audit) is refused with a message instead, since it has no project to read. Before every tool call, both MCP servers also move themselves off a deleted working directory, so a subprocess a tool starts after that inherits the root; a directory deleted while a call is running is not covered.mcp_cwd_deleted_healedlogs once per process.