Skip to content

v7.89.0

Choose a tag to compare

@github-actions github-actions released this 29 Oct 17:32
· 326 commits to main since this release
7caea48

Pairs with engine-service-v0.1.169 (REQUIRED_ENGINE_VERSION 0.1.167 -> 0.1.169; the engine-service-v0.1.168 halves, live in the cloud since 2026-10-29, reach local installs with it). The nx_answer coverage check is on by default from this release (NX_ANSWER_COVERAGE=0 turns it off).

Added

  • RDR-232 measurement: the held-out set has its own oracle label files, and drafted labels are converted from drafter output (nexus-jypmj.3.10). golden_heldout_oracle_labels_v1.yaml (all to-do) and a drafted file sit beside the golden pair; rdr232_phase3_measure --set heldout|golden|both reads the matching files and --from-envelopes --set heldout rescores with drafted labels; rdr232_oracle_drafts.py turns drafter JSON plus the blind packet into the drafted YAML, keyed by chunk hash; the scorer and spot check no longer drop anchors that share an item id. Measurement tooling only.
  • The engine's collection rename, re-embed move, re-home, purge and re-embed counts now act on the unconsumed aspects tasks, which name their collection in their body (nexus-5r2sc.2.6, RDR-235; engine half, rides the next engine cut, engine-service-v0.1.169; no client change, the queue is not wired to any producer or worker yet). A new AspectTaskRefs helper counts, rewrites (collection in the JSON body) and deletes the tenant's available, claimed and dead aspects tasks, in the caller's transaction, and every collection-keyed site calls it: AspectRepository.renameQueueCollection, the catalog rename (renameCollectionTxn), a whole-collection re-home, a re-embed move of one document, the collection purge, the re-embed residue and plan counts, blockingTable, collectionHoldsContent and rehomeStatus. A claimed task keeps its claim and lease and a dead task stays dead. The task rows are locked first (FOR NO KEY UPDATE, in id order) and document_aspects after, the order a claim-fenced aspect write takes, so a rename concurrent with one cannot deadlock. A body that is not valid jsonb (not JSON, or JSON jsonb refuses such as a NUL escape; checked with pg_input_is_valid(body, 'jsonb'), PostgreSQL 16+) is skipped and logged as aspect_task_refs_non_json_skipped. New wire-visible key aspects_tasks: in the rename's counts map and a re-home's moved_by_table (always), in a re-home status's remaining_by_table (when above zero), and as the blocking table a rename's merge refusal names; a collection that only a queued task names is no longer swept as a ghost. Nothing else in the purge's counts changes. Pinned by AspectTaskRefsRenameTest and AspectTaskRefsParityTest (a source scan that fails when a collection-keyed site has no helper call).
  • The RDR-232 Phase 3 measurement driver compares the two arms only where both ran the same plan, grows no plans unless asked, and scores drafted oracle labels apart from hand labels (nexus-jypmj.3.10). A plan-miss nx_answer grows a plan, so the coverage-on arm could match the plan the coverage-off arm had just grown and skip the planner; the driver now pairs on the plan each arm chose at match time, reports the excluded pairs, grows no plans on --live unless --allow-plan-growth is given, binds requests that name no project to --project-dir (default: this repository) so the active-project tier can resolve them, and --from-envelopes re-analyses an earlier run. The oracle gains a drafted provenance, a 95 percent interval on every rate, a document-level label class, a blind drafting-packet extractor and a stratified spot-check sample. unassessed_by_cause gains the key stopped_after_failure (always 0 since the assessment calls run concurrently, nexus-jypmj.17). New in-sample negative-control and near-miss synthesis request sets. Measurement tooling and one additive run-summary key; no change to answers.
  • The nx_answer run summary now counts what each coverage-assessment call came back as, and the envelope's coverage.assessment carries the same counts (nexus-jypmj.3.10, RDR-232 Phase 3; client change, works on every engine; the check itself was off unless NX_ANSWER_COVERAGE=1 in 7.88.0 and is on by default from this release, see the entry below). Per assessment call the reply class is one of support (an accepted record says an item supports a facet), neutral_or_contradict (accepted records, none supporting), all_rejected (records came back and every one failed validation), no_records (an empty reply, or one that is not the check shape) or raised (the call raised), counted in total, per evidence stream, and as an ordered assessment_call_log of [stream, facets, class]. A failed assessment is split into failure_cause unusable_reply, raised_call or mixed. A conflicted facet is counted in facets_conflicted (a subset of facets.covered, whose meaning is unchanged). Unassessed facets are counted by cause in unassessed_by_cause (unusable_reply, deadline_skip, budget_skip, raised_call, other); a facet whose later assessment call was skipped for time or budget, or whose call raised, now carries the reason assessment_skipped_deadline, assessment_skipped_budget or assessment_raised instead of assessment_incomplete. repair_entered (and repair.entered in the run summary) says the run went into the repair step. All of it is closed names and counts, with no question, facet or evidence text with or without trace. This is additive to run-summary version 1: no key changed meaning, the version stays 1, and the engine, which stores the summary as opaque JSONB and checks only that it is an object under the size cap, needs no change. No state, skip or answer shape changes.
  • Measurement harness for the RDR-232 Phase 3 coverage check: a runtime-facet-to-golden-slug map, an envelope-to-RunObservation adapter, item-by-facet oracle labels with a scorer, and a coverage-off versus coverage-on driver (nexus-jypmj.3.10, RDR-232 Phase 3; developer tooling under scripts/bench and bench/queries, no runtime change, works on every engine). bench/queries/golden_facet_map_v1.yaml joins nx_answer's f1..fN facets to the golden required_facets by facet text; bench/queries/golden_oracle_labels_v1.yaml labels which retrieved item bears on which golden facet (provenance fixture, ground_truth or human, never a coverage ledger, so the assessor is never scored with its own output; 9 of 113 request-facet pairs are labelled, 104 are TODO for a human pass) and scripts/bench/rdr232_oracle.py scores the assessor's false-negative, spurious-contradicts and false-skip rates against it. scripts/bench/rdr232_phase3_measure.py runs the golden set with NX_ANSWER_COVERAGE=0 and =1 and writes the reply-class, unassessed-cause, repair-yield-by-facet-origin, conflicted, cost and latency numbers; --live calls the real nx_answer (model calls, telemetry writes) and --stub FILE replays a recorded envelope.
  • The engine applies a scoped traverse's collection or subtree scope before its 500-node cut, and tells the client it did (nexus-jypmj.1.13, RDR-232 section 3; engine half, rides the next engine cut, engine-service-v0.1.168, with the client half in the same commit). The engine cut a catalog walk at 500 nodes ordered by depth and tumbler before any client saw it, so a scoped nx_answer whose walk reached hundreds of code documents lost its in-scope evidence to the cut (the RDR-084 canary: 243 of the 500 nodes were out of scope). POST /v1/catalog/traverse now takes an optional scope_filter ({"collections": [...]} or {"subtree": "1.2"}), removes the non-seed nodes it does not admit before the cut, and answers with a scope_filter marker; GET /version gains catalog_graph_scope_filter_supported. The client sends the filter only to an engine that advertises the flag and requires the marker. Against an engine without it (and on the public cloud edge, which trims /version, until conexus allowlists the flag) a scoped walk that may have been cut is reported as an incomplete traversal and returns the seed documents and no secondary graph evidence, never a containment claim; a walk that was not cut is unchanged. The traversal report gains scope_prefilter, engine_dropped_out_of_scope and secondary_omitted.
  • POST /v1/catalog/manifest/get_many takes a per-document row limit, and a named-source read that exceeds its row budget samples every source instead of dropping the largest (nexus-jypmj.6, RDR-232; engine half, rides the next engine cut, engine-service-v0.1.168, with the client half in the same commit). max_rows_per_doc returns a longer document as an even sample (first and last row included) and the reply adds max_rows_per_doc and each document's true row_counts; GET /version gains catalog_manifest_row_limit_supported. When the recorded chunk counts of the named sources exceed max_manifest_rows_read and the engine advertises the flag, primary_evidence asks for one sample per source at the water-fill level, so no source is manifest_not_read and a sampled one is disclosed as a cap hit. Without the flag the read is the smallest-documents-first one of nexus-jypmj.1.17.
  • The engine stores each nx_answer run's out-of-step dispatch cost (nx_answer_runs.out_of_step_cost_usd: the inline planner call plus the request-extraction call), and nx answer-runs --steps checks a run's cost from both sides (nexus-jypmj.5, RDR-232; engine half, rides the next engine cut, engine-service-v0.1.168, with the client half in the same commit). Liquibase telemetry-017-1 adds the nullable column; /record, /complete, /import and /import_batch carry an optional out_of_step_cost_usd, /query returns it (null when none), and GET /version gains nx_answer_out_of_step_cost_supported. A row that stored the field, whatever its plan_id, must have a run cost equal to its step sum plus the field, so an over-count is flagged; a row without the field keeps the one-sided planner allowance and the run summary's extraction fallback. A plan-match hit that made the extraction call stores the field too, and the column is NULL when the run made no out-of-step call. The client sends the field only to an engine that advertises the flag, and logs nx_answer_out_of_step_cost_unsupported_by_engine once per process against one that does not.
  • The engine ships a literal aspects tuple template, the one aspect-extraction queue per tenant; set NX_TUPLE_CLAIM_LOG_TTL_DAYS to at least 100 or leave it unset before this engine reaches a host, or the engine refuses to boot (nexus-5r2sc.2.4, RDR-235 E3; engine half, rides the next engine cut, no client change). aspects has one key, queue, whose only allowed value is aspects; take is on with max_attempts 6, max_lease_seconds 900 and a default_lease_seconds of 600 (so a worker's inp or renew that sends no lease_s gets the worker's 600 s lease, not a 400); retention_seconds is 7,776,000 (90 days), max_live_rows is 10,000 and claim_log_ttl_seconds is 8,640,000 (100 days). A template's claim-log TTL may only shorten the engine's, so a host whose NX_TUPLE_CLAIM_LOG_TTL_DAYS is below 100 fails the boot check with a message naming the variable; unset is 180 days and passes. The queue is not wired to any producer or worker yet.
  • The engine can hide a tuple from the claim until a database-clock instant: out takes delay_s, nack takes retry_after_s (nexus-5r2sc.2.1, RDR-235 E1; engine half, rides the next engine cut, engine-service-v0.1.169; no client change yet, a client that sends neither field sees no difference). Liquibase tuples-010-1 adds the nullable nexus.tuples.available_at (ADD COLUMN only, no index, no backfill; migration_ddl_per_worker 2148 -> 2149). POST /v1/tuples/out accepts an optional whole-seconds delay_s and POST /v1/tuples/nack an optional retry_after_s; each is stamped as now() + seconds on the database clock, refused with a 400 when negative, fractional or above the template's retention_seconds, and absent or 0 changes nothing. A row with available_at in the future is skipped by in/inp and by the announce-mode rd; a plain rd still sees it, and subspace_stats still counts it as available. A nack with retry_after_s counts its attempt as before, and a nack that dead-letters leaves available_at empty. release, a nack without retry_after_s, the sweep's lapse release and a lapse that dead-letters all clear it with the claim. No signal fires when a delay runs out, but a parked in re-runs its claim on a one-second timer as well as on signals, so it picks the task up within about a second of available_at passing.
  • POST /v1/aspects/upsert can carry the worker's claim, and the write is refused if the claim is not live: optional claim_id and claimant (nexus-5r2sc.2.5, RDR-235 E5; engine half, rides the next engine cut, engine-service-v0.1.169; the client gains only the matching ClaimTaskMismatchError class, the fourteenth TupleError, code = "ClaimTaskMismatch"; no caller sends the fence fields yet, and a client that sends neither sees no difference). The two fields come together or not at all: one without the other is a 400 naming both. When both are present the upsert looks the claim up in its own transaction, before the retired-collection check, and locks the claim's tuples row FOR NO KEY UPDATE, so a re-take of a claim that lapses an instant later skips the row until the write commits. No live claim (lapsed, re-taken, released or already acked) is a 404 ClaimNotFound; a live claim held by another claimant is a 403 ClaimOwnership; a live claim that is not the aspects task for the written doc_id is a 403 ClaimTaskMismatch. Nothing is written on any of them, and the write and the ack that follows it give the same answer for the same claim. AspectHandler renders the tuple errors with their own status instead of a 500. Lock order for every path that touches both tables: the tuples row first, then document_aspects. Engine half: AspectRepository.upsertAspect, TupleRepository.lockLiveClaimRow, AspectHandler; tests AspectUpsertClaimFenceRepoTest, AspectUpsertClaimFenceTest. Engine deploy is safe before the client tag.
  • nack can dead-letter a claim at once: POST /v1/tuples/nack takes an optional boolean dead (nexus-5r2sc.2.3, RDR-235 E4 second half; engine half, rides the next engine cut, engine-service-v0.1.169; no client change yet, a client that does not send dead sees no difference). dead: true dead-letters the claim immediately, for a failure no retry will fix: the attempt is counted, the claim log shows nack then dead exactly as it does when the count reaches max_attempts, the row counts under dead in subspace_stats, and it works under a template with no max_attempts. dead: true on a claim of a lock: true template is a 400 SchemaViolation naming release, as ack is refused on one: a lock must never dead-letter, and the claim stays live. It goes through the same compare-and-swap as the cap path, so ownership and liveness are checked first (403 and 404 as before). A non-boolean dead is a 400, and dead sent with retry_after_s dead-letters and leaves available_at empty. No schema change.
  • out takes mode: "replace" so the newest content wins on a deterministic id, and the ack of a replaced claim returns the task to available (nexus-5r2sc.2.2, RDR-235 E4 first half; engine half, rides the next engine cut, engine-service-v0.1.169; no client change yet, a client that sends no mode sees no difference). Liquibase tuples-011-1 adds nexus.tuples.dirty BOOLEAN NOT NULL DEFAULT FALSE (ADD COLUMN only, no backfill; migration_ddl_per_worker 2149 -> 2150), in its own changeset because tuples-010-1 has already shipped. mode is absent or "replace" (anything else is a 400), and "replace" is allowed only on an id_from: keys+nonce template (any other is a 400 naming it). The response gains "outcome" (created, replaced or dirtied); a plain out keeps {"id": ...}. A consumed, expired or dead row is reset to available (new body, attempts 0, retention clock restarted, available_at from this write's delay_s); an available row, or one whose lease lapsed, takes the new body and keeps its attempts and available_at; a claimed row takes the new body and is marked dirty, and its holder's ack returns it to available (attempts unchanged, claim-log transition ack_dirty) instead of consuming it. Reviving a consumed or expired row counts against max_live_rows and is refused when over; a dead row is already counted. Every replace stamps sender_session with the replacing writer's session (the stamp names the writer of the stored body, and a replace rewrites it), and mode: "replace" on a lock: true template is a 400 whatever its id_from. Every claim clears the mark, so a lapsed holder's dirty flag never reaches the next holder.

Changed

  • nx_answer coverage check: on by default, and its assessment calls run concurrently (nexus-jypmj.17, nexus-jypmj.3.10, RDR-232 Phase 3; client change, works on every engine). Sam decided on 2026-10-28 that the coverage check is on: with NX_ANSWER_COVERAGE unset or blank the runner assesses the evidence before the final reduction; 0, false, off or no turns it off and nx_answer is what it was before Phase 3. A value that is neither an on nor an off word (disable, off!) still reads as off, not as the default, and warns once, so a typo never runs the assessor. The default ships with the latency fix the live canary asked for: with the check on it added 118 s, because one operator_check call per evidence stream ran in a row, the 65-anchor graph call outran its 60 s timeout, and a call that raised stopped the loop. Now the calls run at most 4 at a time, or fewer when the direct-API backends' NX_LLM_MAX_CONCURRENCY is set lower (those backends gate every HTTP attempt at it, so a call past it would only wait for a permit inside its own timeout; the claude -p backend has no such gate and its 4 concurrent subprocesses are unmeasured), each call is cut at the assessment stage's share of the deadline (0.4 of what is left) even if the model call ignores its timeout, and the cut now kills the claude -p process tree (a call cut from outside used to leave its child running to the end of its turn), and the USD allowance is checked as each call starts, counting the calls in flight and the starting call at the mean cost of those finished, or at a 0.02 USD prior before any call has reported a cost (the live canary's 4 calls cost 0.0166 USD in all); one call always starts when none is in flight and the allowance is above zero, so the overshoot is expected to stay about one call past a soft budget while real call costs stay under the prior. A call killed by the cut reports no cost (the CLI states its spend only in its result event), so its step record carries an unknown cost and the allowance does not count what it spent. A stream of more than 20 anchors is split into balanced packs of at most 20, each its own call, so the 65-anchor graph stream is four calls of 16 to 17. A call that raises or replies unusably affects only its own facets: the other calls still count, a facet another stream supports stays covered, and the unassessed counts, reply classes and call log are in plan order whatever order the calls finish in. unassessed_by_cause.stopped_after_failure stays in the wire form and is now always 0, because nothing stops after a failure. The terminal step's elapsed_ms (and so nx answer-runs --steps) no longer includes the assessment and repair, which have their own step records and the run summary's assessment_latency_ms; the run's step records are listed by step index, not in the order the concurrent calls finished; and nx answer-runs --steps counts the assessment stage once in by_plan.median_elapsed_ms (the stage's assessment_latency_ms, not the sum of calls that overlapped), where a plain sum reports more than the run took. The 50 model-seconds (down from 130) is a unit-test timing model of the canary at 1/100 scale, not a measurement: no live run of the concurrent design was made. The live figures are the serial design's, one request, one run: 29.4 s and 0.433 USD with the check off, 147.3 s and 0.430 USD with it on, of which the assessment took 118 s and 0.0166 USD. The check adds latency and a small cost per run and can withhold an answer as uncovered. The suite pins NX_ANSWER_COVERAGE=0 so tests over operator stubs do not reach the boundary.

  • nx_answer coverage check: an ambiguous named source no longer withholds the answer (nexus-jypmj.3.17, RDR-232 Phase 3; client change, works on every engine). When the coverage check runs (it is on by default from this release; NX_ANSWER_COVERAGE=0 turns it off), a request naming a source that two projects carry (RDR-049 in nexus and ART) reported the source as missing, marked its facet source_missing, and skipped generation: three golden requests came back uncovered at zero cost while the coverage-off arm answered them. Such a source, and one whose lookup failed, timed out or hit its candidate cap, is now unresolved: it is disclosed in coverage.sources and coverage.primary_source, named in the insufficient-evidence notice, and blocks no facet, which is assessed on the evidence that was read. A source no document carries stays missing and still decides its facets as source_missing.

  • /conexus-prose-edit:prose-edit asks its questions as choice prompts, and its setup walks a newcomer in (nexus-scmcs, plugin change; works on every engine). The first setup run on a 7.88.0 install listed about 180 unmapped Markdown and HTML folders (tests, fixtures, agent prompts) and asked the author to classify them in free text, with no defaults, no explanation of a genre and no closing step. setup now reports at most eight genre_candidates, folders that look like documentation, each with a genre suggested from the folder name, all six genres ranked and a sample file; a count of the files left out (unmapped_other); ready; and a try_path. It replaces the no_genre listing. Tests, fixtures, scripts, hidden folders and similar are never offered. The skill asks through AskUserQuestion: the setup wizard (the temp-folder permission rules, which can be added to ~/.claude/settings.json on a yes behind Claude Code's own approval prompt; one question per candidate folder with the suggestion first; example passages) ends with Ready. and a command to try; the run-time genre question, which no longer asks for the genre in free text and offers to remember the answer for the folder (BRIEF suggest-genre <path>); and the accept and reject step, which offers each edit as a tick box instead of asking for numbers (more than 16 edits still take numbers for the rest). The apply step still shows its dry run first and asks to apply, change the picks or cancel. The README opens with a three-step quick start and a table of the six genres, whose wording the scripts share.

  • mineru is the default PDF extractor; auto and docling run as mineru with a one-line notice (nexus-tr1rj.5, RDR-237 Phase 1 Step 3; client change, works on every engine). pdf.extractor, nx index pdf --extractor, nx dt index --extractor and nx dt capture --extractor default to mineru (they were auto). auto and docling are still accepted, in the config and on the command line, and run as mineru; the first use in a process prints one line saying so (notice: pdf.extractor in your config says 'docling', which now runs as 'mineru' ...), and later uses in that process print nothing. A pdf.extractor value that is none of mineru, pymupdf, auto or docling falls back to mineru with a warning (it fell back to auto). pymupdf is unchanged. There is no formula pre-screen and no first pass any more: every PDF goes straight to MinerU, and a page whose MinerU attempt fails walks the per-page ladder (retry without formulas, then the PDF's own text layer, then unextracted). A MinerU that cannot be imported on an install whose PDF stack reports itself present, or that fails on a PDF, still stops that PDF with a message of its own (PDF <name>: MinerU is not importable ... or PDF <name>: MinerU extraction failed: ... Run nx doctor); the old text said the PDF "contains formulas (detected N)", which was only true of the removed screen. --on-formula-oom is retired: nx index pdf still accepts fail or docling after it, ignores the value, prints one notice, and no longer lists the flag in --help. The library functions index_pdf, batch_index_pdfs and PDFExtractor.extract lose their on_formula_oom parameter; PDFExtractor.extract raises ValueError for an extractor name that is none of the four. Every PDF now runs MinerU, about 4 to 10 s per page, and the first PDF on a box downloads about 1.2 GB of model weights. nx index repo, nx collection reindex and nx dt index run MinerU on every PDF they index; --extractor pymupdf (or pdf.extractor: pymupdf) is the fast, text-only choice.

  • nx_answer coverage check: a contradicted facet now counts toward a usable answer, so a result whose only settled facets are contradicted returns a partial answer with the contradiction named ahead of the prose (nexus-jypmj.3.13; client change, works on every engine). When the coverage check runs (on by default from this release), [insufficient-evidence] now means the evidence neither supported nor contradicted any facet, where 7.88.0 said it covered no facet.

  • A MinerU breaker trip or machine-resource error stops the MinerU work of nx dt index and nx index repo and keeps the rest; a --extractor pymupdf rerun over a tripped MinerU buffer starts clean; nx dt index and nx dt capture read pdf.extractor (nexus-tr1rj.13, nexus-tr1rj.14, RDR-237; client change, works on every engine). A breaker trip (MineruSystemicFailure) or MineruMachineResourceError (POSIX semaphores exhausted) names the machine, not the file, so nx index pdf --dir (which already stopped on a trip), nx dt index and nx index repo now all stop attempting PDFs once one is seen and list the PDFs not attempted, and exit non-zero. nx dt index classifies it as a breaker trip, not an unexpected defect (no dt_index_unexpected_error, no traceback), and keeps indexing markdown and web records. nx index repo contains it like the quality gate instead of aborting the run, so code, prose and RDR files are indexed; the PDFs it failed or did not reach hold the --since-head base back. A pymupdf rerun over the pipeline buffer a tripped MinerU run kept (or one that names no rung, or a finished MinerU extraction) wipes it and extracts every page from the text layer, so the document is recorded pymupdf, not mineru+degraded. nx dt index and nx dt capture take --extractor from pdf.extractor when no flag is given.

  • nx doctor on a managed (cloud) deployment gives every row that needs local database credentials the same warning, never a checkmark (nexus-njn1a finding 10; client change, works on every engine). Storage service health, Schema migrations, RLS policies, tuples.dead_tuple_ratio and tuples.sweep_freshness already warned on a cloud box; Tenant partitions, Model partitions and Partition leaves showed the same fact as a green "not applicable". All eight now render a ⚠ with one reason text: managed deployment — not probeable from this client, so this check was not run: .... The check was not performed, so a checkmark would be false (Sam, 2026-10-28, nexus-g7ijj). A local box with no pg_credentials keeps its own text. The setup row stands down on a cloud box exactly as before, by recognising that one reason. It also closes a hole this change would otherwise have opened: nx daemon service install-binary and the upgrade finish pass classify the store through the Schema migrations row, and on a managed box they read "clean" after examining nothing; an unperformed row is now unknown whatever it is drawn as. The Embedding family row says what it can: when the engine's /version names no profile (and this client has none to read it against) but lists both the bge and the Voyage family, it passes and says both are served (the managed cloud engine reports this, measured on the live cloud on 2026-10-28); otherwise its "not applicable" now names the real cause (the engine reports no embedding_profile, and a managed engine is not read against this client's local profile) in place of "could not be determined".

  • nx telemetry baseline --since labels the nx_answer runs timestamps as the window's: oldest_in_window= and newest_in_window= (nexus-njn1a finding 11; client change, works on every engine). They were printed as oldest= and newest= beside the all-time total=, but both come from the --since window. Without --since the text is unchanged, and the JSON keys (oldest_created_at, newest_created_at) are unchanged.

  • The nx_answer request-extraction prompt now draws the lookup/synthesis line where RDR-232 section 1 does: several connected facts from one passage of one source are a lookup; a synthesis gathers aspects across sections, sources or history (nexus-jypmj.13, RDR-232 Phase 2; client change, works on every engine). The prompt used to define synthesis as "a unified account of several aspects" and ask for one facet per aspect, so a compound question about one passage read as a lookup only when the extraction model departed from its prompt (Haiku did; Opus on the same prompt said synthesis, and so would a model alias re-point or an openai_compat backend). The first-pass rule still leaves a question with more than one facet unknown and sends it to the extraction, because the wording cannot say whether the facts share a passage. A new labelled set, bench/queries/golden_compound_lookup_v1.yaml (6 two-fact single-source lookups), and --set compound on bench.rdr232_extract_measure measure the class. The extraction was re-measured with the new prompt on 2026-10-28 (98 calls: Haiku 67, Opus 31; no timeout, error or invalid reply): Haiku reads 5 of 6 compound single-passage requests as lookup and Opus 6 of 6, and the five approved gate thresholds read the same as before.

  • The RDR frontmatter id is recorded on the catalog document when nx index repo or nx index rdr registers it, and a named-source sibling lookup reads it first (nexus-jypmj.7, RDR-232; client change, works on every engine, local and cloud). Where one RDR number has several files, nx_answer picks the one whose frontmatter carries the id; it used to learn the id from the rdr-frontmatter-v1 aspect row, which the aspect worker fills later, or from the file where the server could see it. The id is stored under meta.rdr_frontmatter_id ("" for a header with no id; absent when the header did not parse). Existing registrations get it on the next nx index repo of the repo: an RDR whose document lacks the key, or carries a different one, is written once and then left alone.

Removed

  • The Docling PDF extractor is gone: code, dependency, CI priming and doctor row (nexus-tr1rj.5, RDR-237 Phase 1 Step 3; client change, works on every engine). Docling extracted every PDF that the formula screen did not send to MinerU, and its whole-document convert went silent for hours on some PDFs (RDR-237 has the measurements). Removed: the router's formula screen and Docling pass, the per-process converter cache, the one-page Docling degrade, the docling_title metadata key and its first place in the title chain (a PDF's title is now its metadata title, then its first heading, then its file name; the MinerU path reads the PDF's own title, author and creation date with PyMuPDF, as the text-only extractor does, so a MinerU document's catalog year now comes from the PDF's creation date, which may not be its publication year), and NEXUS_DOCLING_ARTIFACTS_PATH as a setting. docling-slim leaves the pdf extra and docling-core, docling-ibm-models, docling-parse and docling-slim leave src/nexus/_install/constraints.txt. uv.lock loses exactly 14 packages that only Docling needed: accelerate, doclang, docling-core, docling-ibm-models, docling-parse, docling-slim, jsonref, langcodes, latex2mathml, psutil, rtree, shellingham, tabulate and typer. No package changes version, and pluggy leaves the install constraints (it stays in the lock for pytest). pillow stays at 12.3.0 (the comment in pyproject.toml that blamed docling-slim for the cap now names fastembed alone, which caps pillow below 13 through 0.9.0), and pypdfium2 stays at 4.30.0, which pdftext 0.6.3 pins exactly. nx doctor's PDF extraction (docling) row is now PDF extraction (mineru) and checks mineru, cv2, torch and torchvision; the MinerU server row says "PDFs" where it said "math PDFs". A new Docling model cache row (a warning, never red) appears only on a box that still has the models Docling downloaded, about 1.1 GB: directories whose names start models--ds4sd--docling, models--ds4sd--CodeFormula or models--docling-project-- in the Hugging Face hub cache (~/.cache/huggingface/hub, or where HF_HUB_CACHE, HUGGINGFACE_HUB_CACHE, HF_HOME or XDG_CACHE_HOME puts it; ~ and $VAR are expanded), in docling-tools' default ~/.cache/docling/models, and in the directory NEXUS_DOCLING_ARTIFACTS_PATH named, if set. The row prints the size and an rm -rf command for the first two; for the NEXUS_DOCLING_ARTIFACTS_PATH directory, which you chose and may share, it prints no command and says to check that it holds only Docling models. Nothing deletes the cache for you, and a box that never ran Docling shows no row. CI: the prime-docling action, the "Prime docling artifacts" and "Verify docling extracts" steps (in the shard matrix and in the affected-integration job) and scripts/ci_warm_docling.py are deleted; no MinerU step is added to PR CI. The job timeouts that the Docling model cache once forced up are unchanged, because no CI run without the cache has been measured. Documents already indexed keep their extraction_method of docling, mineru+docling-degraded or pymupdf_normalized; they stay readable and --where extraction_method=docling still finds them, and nx index pdf --force re-extracts any you choose. The install sizes quoted in the README, the docs, the setup tool and the .mcpb manifest were measured on 7.86.0 with Docling in the stack and have not been measured again, so they now overstate the PDF support; only the list of what it contains changed.

Fixed

  • nx_answer counts the structured request extraction's call in the run's cost_usd and in budget_usd's running spend (nexus-jypmj.9, RDR-232 Phase 2; client change, works on every engine). The Haiku extraction call (about 0.002 USD, on about two thirds of requests) was reported in request_coverage.extraction only, so the envelope's cost_usd, the stored nx_answer_runs.cost_usd and the cap a budget_usd call stops at all left it out, and nx answer-runs under-reported every run that made it. Its usage now joins the sink the inline planner's does, and the run's out-of-step share (planner plus extraction) is what the run stores in out_of_step_cost_usd (nexus-jypmj.5). nx answer-runs --steps follows: a row that stored the share is checked against the step sum plus the share; for a row without it, a run summary that records an extraction cost agrees at the step sum plus that cost (exact, so an over-count is still flagged), or at the step sum alone for a row written by 7.88.0, which recorded the call in the summary only; a row without a recorded extraction cost is held to the step sum as before.
  • The answer-kind gate's library check names each undeclared builtin row with its id, and does not raise on a malformed dimensions value (nexus-jypmj.15, RDR-232 Phase 2; client change, works on every engine). plan_eligibility.undeclared_builtin_names and the once-per-process warning listed rows by name, and four live builtin rows are all named default; each entry is now <name> (id <n>). A row whose dimensions is not a string (a dict or list, unhashable) is skipped as unreadable instead of raising TypeError out of the check.
  • The library_not_reseeded warning names what actually clears the state (nexus-jypmj.16, RDR-232 Phase 2; client change, works on every engine). It said nx plan reseed, which cannot clear a builtin row that the reseed itself refuses to write: a template the installed conexus rejects (the reseed prints seed load error; only an upgraded conexus clears it) and a template whose description another row already holds (not reconciled ... description already belongs to library row id=N; it clears when row N is deleted with nx plan delete N and the reseed is run again). The warning's remedy, docs/configuration.md and docs/cli-reference.md now say so.
  • A persistently failing structured request extraction logs one warning per process and has an nx doctor row (nexus-jypmj.16, RDR-232 Phase 2; client change, works on every engine). After three extraction calls in a row fail (no claude CLI and no API key, an openai_compat endpoint that answers outside the schema, a timeout every time) while the answer-kind gate is enforcing, the process logs answer_kind_gate_extraction_failing once: every request keeps its first-pass kind and the gate enforces on it, the regime that lost lookup recall in the held-out measurement. The new "Answer-kind extraction" row of nx doctor reads the same preconditions without a model call: it names the backend and model, is not applicable (a pass) when the extraction or the gate is switched off or the box has no LLM backend at all, and warns for an openai_compat endpoint or a backend setting that cannot serve.
  • The routing guards no longer wait on their routing_events write, which now gets a 5 s budget of its own in a detached child (nexus-njn1a finding 9, nexus-njn1a.1; plugin change, works on every engine, live at the next plugin release). The hooks POSTed each deny or escape to the engine with a 0.25 s limit on every socket step, and 646 of the 650 entries in one box's drop meter (dropped_writes.jsonl) were cause=timeout, 641 of them routing_events. Raising the limit inside the hook would have made every logged deny and routing-allow escape wait for the engine before it could be emitted (a 1.5 s limit measured 1.58 s per deny against a stalled engine, 0.35 s at the old limit). The POST now runs in a detached child with a 5 s total deadline; the hook emits its decision and exits without waiting (0.07 s against the same stalled engine). A failed POST still lands one drop-meter record, written by the child; a hook that cannot start the child records the drop itself as other. Nothing is spooled or retried. An answer that arrives after the child's deadline counts as a drop although the engine may hold the row. The 8 capability_census drops are historical: that writer was deleted (nexus-0r1uz). What made those POSTs time out at 0.25 s per step is not established: an unauthenticated POST to the route measured 0.23 to 0.33 s in total with no single socket step over about 0.17 s, and the authenticated request was not measured.
  • nx_answer with the coverage check on (the default from this release) no longer withholds the answer as uncovered after recording every reserved chunk of a named source as dropped (nexus-jypmj.3.14, RDR-232 Phase 3; client change, works on every engine). The coverage assessment's own operator_check call went through the run's operator hydration while the reserved stream was still marked at the terminal step, so the stream read the assessment payload as the terminal input and recorded every reserved item dropped (reserved_stream.dropped_before_terminal of N of N); the repair's second ledger then marked the named source missing and every facet source_missing. The assessment now runs with the reserved stream out of scope; coverage on and off agree on what reaches the terminal reduction.
  • nx answer-runs no longer counts a trace=False coverage non-answer as executed-ok, and the nx_answer run summary no longer carries the question as a facet's text (nexus-jypmj.3.15, nexus-jypmj.3.16, RDR-232 Phase 3; client change, works on every engine). A trace=False row records its text as [redacted], so the coverage notice could not classify it and an uncovered run landed in executed-ok while its traced twin was degenerate; the row's run summary now decides: an nx_answer summary whose coverage_state is unavailable puts the row in degenerate as non_answer/uncovered, non_answer/no_evidence (no evidence or a scope conflict) or non_answer/no_usable_answer (the prose was no usable answer, or the summary's coverage block was cut). A whole_question facet's text is the question itself, so the summary lists that facet with its id, origin and topics and no text at either trace setting; finer facets keep their text on traced runs.
  • An unusable MinerU time limit now stops a PDF command before it changes anything, and a skipped MinerU server restart is no longer reported as a replacement (nexus-tr1rj.24, nexus-tr1rj.25, RDR-237; client change, works on every engine). pdf.mineru_page_timeout_s times pdf.mineru_page_batch above 240 s (or a value that is no integer) used to be refused only when MinerU started on a document, after its catalog row was written: nx index pdf X --extractor mineru --force with pdf.mineru_page_batch 2 turned a complete document into failed, ran the --force orphan cleanup over its chunks, and with a different --collection pointed the row at an empty collection. nx index pdf (a file and --dir), nx collection reindex (before it purges), nx dt index and nx dt capture now ask first, when the extractor is mineru, and exit 1 with Error: pdf.mineru_page_timeout_s (180 s) x pdf.mineru_page_batch (2) = 360 s, above the 240 s ..., the message the extraction gave, with the catalog, the chunks and the collection untouched. nx index pdf and --dir refuse even when the PDF is already indexed and unchanged (they do not look first); --extractor pymupdf is not affected. nx index repo used to index code and prose and then abort at the first PDF, with the PDF's catalog row already written; it now holds the PDFs that need indexing (a PDF stale only by embedding model included; PDFs the walk finds current are left alone), touches no catalog row and no chunk for them, lists them with the setting, indexes the code, prose and RDR files, and exits 1 at the end. index_pdf refuses the same way for a library caller, and the check at the start of extraction stays as a backstop. The line MinerU server timed out on page(s) N — replacing it (pid P). was printed before the restart was tried, so a restart skipped inside the 120 s machine-wide cooldown still said the server was being replaced although it stayed up. replacing it (pid P) is now printed only once the restart is decided (after the restart limit, the spawn policy, the cooldown and the pid check have let it through), before the stop and the health wait of up to 60 s, followed by replaced it (old pid P). A restart that is skipped prints only not replacing it (pid P) with the reason: the cooldown, the restart limit or spawn policy, a pid that is not recorded, or a pid whose process could not be identified (nothing was stopped); a replacement that did not come up says tried to replace it.
  • nx store list and the MCP store_list no longer abort on a chunk row whose ttl_days is out of range or not a number, and a bundle or a note write can no longer carry a TTL past 36500 days (nexus-njn1a, 7.88.0 shakedown finding 6; client change, works on every engine). The listing computed timedelta(days=ttl_days) and caught only ValueError, so one row with a ttl_days of 10**12 ended the whole listing with OverflowError, and a string ttl_days died on the > 0 comparison. The listing and the context annotation line (expires in Nd) now read a chunk row through metadata_schema.chunk_ttl_days: a ttl_days that is not a plain positive number (a string, a bool, NaN, zero, a negative) or is above timedelta's range (999,999,999 days) is permanent, and the listing also shows permanent for one whose expiry date falls past the year 9999 (about 2.9 million days on). A numeric string such as "30" used to render expires in 30d in the annotation line; on a chunk row it is now read as permanent, as the expiry sweep (is_expired) already read it. A note's own TTL in the catalog is still read by note_ttl_days, which accepts "30" and cuts a float to whole days, so the two readers differ on strings; that is unchanged. Bundles: nx catalog import wrote a record's ttl_days verbatim, past the 36500-day (100 year) limit nx store put --ttl refuses. Export and import now read a ttl_days above 999,999,999 or not finite as permanent, and cap one from 36501 up to that at 36500, with a recovery_bundle_ttl_clamped warning naming the original value. put_note, write_note and T3Database.put now refuse a ttl_days above 36500 with a ValueError, as they refuse zero; nx memory promote caps a T2 entry's remaining TTL at 36500 (the MCP memory_put takes any integer) instead of failing on it, including an entry whose TTL is too large for date arithmetic (about 2.9 million days on, where it raised OverflowError). nx store import still copies a chunk row's metadata whole; every reader above tolerates what that can carry.
  • The store_get_count_unreported warning names the fallback that path really has (nexus-njn1a, finding 7; client change, works on every engine). It always said "Degrading to the pre-count store_get_truncated_at_limit heuristic", which is true for the collection stub's get but not for the paged reader the expiry sweep guard and the recovery export use, which applies no heuristic. That path now says it has none, so a dropped row cannot be seen on an engine that sends no count. Each of the two messages is logged once per process, so whichever path runs first no longer decides the text the other shows.
  • nx index rdr <dir> on a directory outside any git repo now leaves nx enrich aspects something to attribute (nexus-njn1a, finding 12; client change, works on every engine). The documents registered under a curator owner, which has no repo_root, with a path relative to the directory and no source_uri, so nx enrich aspects logged aspect_uri_for_relative_without_root and skipped every one as unattributable_identity. Such a document now registers by its absolute path, as nx index md and nx index pdf register theirs, and the engine derives its file://<absolute path> source_uri (the raw path, not a percent-encoded one, so a directory with a space or a non-ASCII character matches what the engine and the staleness check derive). Two non-git directories with the same name, which shared one curator owner and so one document per relative path, now get a document each. A document an earlier release registered by its relative path is adopted on the next nx index rdr run and given its source_uri, unless it holds a different one whose path still exists (another directory's): then it is left alone with a curator_legacy_document_not_adopted warning that names the path it holds, and the file gets a new document. A non-git document's identity is its location, as for nx index md: a document registered by absolute path whose directory later moves is a new document at the new path, and the old one is left for nx catalog reconcile-stale. The one exception is a legacy relative-path document whose recorded path no longer exists, which the moved directory adopts and re-stamps with its new source_uri instead of leaving it behind. A non-git directory that later becomes a git repo keeps its document. The source_uri stamp on an existing document is its own advisory write, so a second document already holding that URI no longer stops the document's chunk_count and indexed_at from updating. Inside a git repo nothing changes.