Skip to content

v7.87.0

Choose a tag to compare

@github-actions github-actions released this 26 Oct 17:31
· 281 commits to main since this release
fcb799c

Pairs with engine-service-v0.1.165 (REQUIRED_ENGINE_VERSION raised from 0.1.164), deployed to the cloud on 2026-10-26 at 14:49Z ahead of this tag. Its two changesets (catalog-046-1, vectors-038-1) change no existing data. The cloud's data-token mint limits were raised with that deploy (burst 40, 10 a minute per credential and tenant, 40 a minute in all). This release ships RDR-232 Phase 1 for nx_answer, the engine halves of per-document section labels and where filters, the superseded-collection skips, the data-token refresh fix, the conexus-tool-spill fixes, the plugin-only fast lane that replaces the anchored plugin channel, and the removal of the subagent git-integrity check.

Added

  • An explicit owner or project binding now narrows the secondary evidence streams, not only the named source (nexus-jypmj.1.18, RDR-232 Phase 1; found by the golden fixture: with scope="rdr" and the ART owner, another project's RDR-084 still reached the reducer through the vector stream, 6 of the 100 reducer items in the oversized-input case). After the section 2a corpus narrowing, each corpus-taking retrieval step expands its corpus to collections by the same routing search and query use and keeps only the code, docs and rdr collections whose owner (catalog row, 1.2 is 1-2) matches; knowledge and other types pass. A step left with no collection is skipped, contributes no evidence and is reported (scope_diagnostics entries with scope_kind: "owner", the owners and the collections removed; evidence_diagnostics.scope_check.owner_narrowing; the text-mode [scope conflict: ...] line), and never runs unscoped; a step pinned through collection or collections keeps its pin. The traversal policy takes a new optional allowed_owners and drops non-seed documents of a project-owned collection that lie outside the owners (dropped_out_of_owner); documents in other collections (knowledge, paper) pass, as in the vector stream. A project-owned collection whose owner cannot be read (an old two-segment repository name such as docs__nexus-571b8edd, or one with no catalog row whose name fails the grammar) is kept and listed as unattributed, never dropped as foreign. The single_query fast path applies the same filter. An owner that resolution inferred narrows nothing, and neither does a qualifier that conflicted or did not resolve: the run proceeds unnarrowed and scope_check.owner_narrowing.not_applied says why. A catalog-subtree scope is the owner qualifier for source resolution only and narrows nothing (RDR-232 section 2a, unchanged). nx_answer also strips caller-supplied _nx_owners and _nx_scope bindings, which are its own.
  • nx_answer reserves the bindings source, owner and project for the caller's evidence request (nexus-jypmj.1.2, RDR-232 Phase 1). They are read from bindings and removed before plan binding resolution and before plan_match, so no plan is offered or run on them. plan_save and the library's save path refuse a plan that declares one (in required_bindings, optional_bindings or default_bindings, or as $name in a step's args); a plan already in the library that does is excluded from matching and logged, not rewritten or deleted. A catalog-subtree scope with no owner binding is itself the owner qualifier; two qualifiers agree when one tumbler is a segment prefix of the other, otherwise the request reports the conflict and picks nothing. The structured envelope has a new, always-present evidence_request key (None when no reserved binding was passed), and in text mode a request that could not be honoured adds a one-line [evidence request: ...] notice after any leading [budget exhausted ...] or [non-answer: ...] marker. Resolving the named source to a document is a later step (nexus-jypmj.1.4).
  • nx_answer(scope=...) now narrows a plan's declared corpus instead of deferring to it (nexus-jypmj.1.3, RDR-232 Phase 1). Before, a corpus a plan pinned beat the caller's scope, so scope="rdr" on the factual-lookup builtin still searched corpus: all. Now a corpus-prefix or full-collection scope narrows the resolved corpus of every corpus-taking retrieval step (search, query and the scoped search primitives), in plan_run and on the single_query fast path alike: no corpus gives the scope, all gives the scope, entries under the scope (rdr__1-1__... under rdr) are kept, and a scope under an entry (knowledge__x under knowledge) narrows that entry to the scope. A step whose corpus has nothing under the scope is a scope conflict: it is skipped, never run unscoped, and its output is an empty structured result plus a scope_conflict marker, so a later $stepN.ids still resolves. A run whose retrieval steps all conflict takes the existing no-evidence path and the notice names the conflict; a partial conflict adds a [scope conflict: ...] line after any leading [budget exhausted ...] or [non-answer: ...] marker, after the answer shape is classified. The structured envelope has a new, always-present scope_diagnostics list (step_index, tool, plan_corpus, caller_scope, scope_kind, outcome, resolved_corpus; empty when no scope was given). Steps pinned through collection/collections keep their plan and are reported unscoped_collection_pin; a catalog-subtree scope (1.2) is not a corpus and is not intersected. The builtin plan YAML is unchanged, so a call with no scope resolves the corpora it did before. On the fast path a corpus-prefix scope used to ride as subtree and be rejected as an unknown owner; it now goes into corpus. A run in which every retrieval step conflicted with the caller's scope is recorded but counts no success or failure for the plan (it is the caller's error, not a plan failure); the scope description now says a catalog subtree filters only the single-step fast path.
  • nx_answer resolves the named source to a document and says when it cannot (nexus-jypmj.1.4, RDR-232 Phase 1). The source binding takes one reference, a list, or a comma-separated string (evidence_request keeps its single-source source and gains a sources list). Each RDR id is resolved inside the owner qualifier by one capped catalog search (at most 100 candidates, two searches and one owner check per source, six sources), reusing rdr_canonical's basename identity and no-guess rule: with no qualifier and two projects holding the number the result is candidates, never a pick; a qualifier that names no registered owner is reported, not searched past; a search that reaches its cap is not resolved. The structured envelope has a new named_sources key (None when no source was named) and text mode adds a [evidence request: named source ...] notice for an ambiguous or unresolved source. The module nexus.plans.primary_evidence also reads a resolved document's manifest and selects section-diverse evidence under stated caps, labelling chunks from a manifest row's section_title only where labels_written is true and from the chunk's own headings otherwise; the runner merges that selection into the reduction operators' input (nexus-jypmj.1.6, below). A failed owner qualifier is stated once in text mode, and a source resolved without an owner qualifier gets a not-owner-qualified notice (on the single-step fast path it says the plan has no primary evidence stream). The envelope keys scope_diagnostics, evidence_request and named_sources are documented in docs/cli-reference.md.
  • traverse takes an evidence policy, and nx_answer gives every traverse step one (nexus-jypmj.1.5, RDR-232 Phase 1). New optional argument policy (allowed_collections, preserve_seed_documents, max_documents, max_chunks, require_link_role); without it the result is exactly what it was. With it, the policy runs after the catalog walk and before any manifest is read: documents outside allowed_collections are dropped, the rest ranked by link role (citation, implementation, related), then link distance, then tumbler, the non-seed ones capped at max_documents (default 50, not calibrated; nx_answer supplies 50 only when the caller gave a scope, and with no scope or scope="all" it supplies the engine's 500-node cap, which binds nothing, until nexus-jypmj.1.12 has measured a cap, so the document cap does not bind an unscoped traverse); manifests are read for the retained documents only; max_chunks (at most 300) is spent with a reserved share per document; seed documents are never evicted by the rank or the cap. The result adds chunk_collections (one collection per id, aligned with ids) and traversal, a report with the walked, retained and dropped counts, counts by document content type (a catalog row field, not a parse of the collection name), widened (set only when no scope was given and an expansion admitted a corpus the seeds do not have) and scope_conflict (an implementation-role step whose expansion the caller's scope removed). The engine caps a walk at 500 nodes (CatalogRepository.MAX_GRAPH_NODES); a response with that many nodes is reported as possibly_truncated with contained: false, logged as traverse_graph_node_cap, and counted by capped_traversal_count for the engine pre-cap filter decision (nexus-jypmj.1.13). The detector also counts seeds missing from the reply, because the engine joins every non-seed node to a live document and so only a dead seed can disappear after the cut (499 nodes plus one missing seed reads as possibly truncated); it stays a detector, and the engine's scoped-result marker is what stops the cut. A policy that cannot be applied returns no graph evidence and a warning, never an unrestricted walk. The plan runner derives the policy from the caller's scope and the step's link types; of a policy a plan declared it merges only what tightens (a lower max_documents or max_chunks, a require_link_role that narrows factual) and ignores the rest. A catalog-subtree scope is now enforced on the graph stream inside plan_run (a document outside the subtree is dropped before its manifest is read); before, only the single-step fast path filtered by subtree. A bare-string link_types is one type, and a scope that is a list is used when it holds one entry and otherwise fails the policy parse (graph stream omitted with a warning) instead of becoming a prefix that admits nothing. On the RDR-084 fixture under scope="rdr" the graph stream was 300 code ids at the id cap; it is now the 18 rdr chunks of the two RDR documents and the 115 code documents are dropped before their manifests are read. The MCP wire snapshot gains the policy parameter. An unscoped nx_answer traversal is not the legacy result. nx_answer always supplies the policy, so with no scope the document cap does not bind, but the documents are still ranked by link role then distance instead of coming back in graph order, the chunk budget is spent with a reserved share per document instead of chunk_ids[:300] in graph order (past 300 chunks a later document is no longer cut to nothing), the output carries chunk_collections and traversal, and a step whose link types are one role admits only that role. A pinned test compares the two outputs.
  • nx_answer merges the named source's primary evidence into the reduction ahead of the plan's own items (nexus-jypmj.1.6, RDR-232 Phase 1). When a source binding resolves and its document yields evidence, nx_answer builds a reserved stream from it (at most 50 items, below the operator input cap of 100) and passes it to plan_run. At the first operator that consumes retrieved evidence, in ids auto-hydration and in an explicit inputs list (including extract, the first reducer in review-default), the runner puts the reserved items ahead of the plan's items, drops any vector hit that is the same chunk (by chash for ids, by text for inputs) and keeps the primary copy with its [primary source ...] attribution. The _OPERATOR_MAX_INPUTS cap runs after the merge, so it cuts only the plan's own items; an explicit-inputs operator is capped at that point only when a stream was merged, so a run with no source keeps today's uncapped inputs. The continuation envelope merges the same stream, so the caller-side reduction sees the same evidence as the bundle and per-step routes. The single_query fast path has no primary stream and says so. The structured envelope gains primary_evidence and reserved_stream keys (both None when no stream was built; see docs/cli-reference.md), and text mode adds an [evidence request: ...] line when a read stream was never merged, items exceeded the share, or a rank or filter dropped reserved items before the final reduction. That drop count is made only where it is true: not for the deferred refs inside a bundle, and not after a summarize, generate or extract, which reduce rather than drop. A manifest longer than max_manifest_rows_read (3000 rows) is spread-sampled after the catalog reply; the reply itself is not yet limited by the service (nexus-jypmj.1.17).
  • nx_answer adds the resolved RDR's T2 metadata and latest gate record to the reserved stream (nexus-jypmj.1.7, RDR-232 Phase 1, assumption A7). When a source resolves to an RDR file (by id or by tumbler), nx_answer reads that RDR's two T2 records with exact get calls (never a search or a listing) and puts them at the front of the reserved share, ahead of the chunks, so neither the share cap nor the operator input cap evicts them; a failed chunk read still leaves a stream of the records. The T2 project is <owner name>_rdr for the repo owner that holds the resolved document, never taken from the request: ART RDR-084 reads ART_rdr and cannot read nexus_rdr. An owner that is not a repo owner, has no name, or is not in the catalog leaves the project undetermined and nothing is read. Titles are tried in the shapes the RDR tooling writes (84, 084, RDR-84, RDR-084, then <shape>-gate-latest). An ambiguous, unresolved or non-RDR source gets no records and the envelope says why. Caps (LifecycleCaps, on the wire): 6 records, 24 T2 reads, 4,000 characters a record, 18,000 in all, 20 seconds (or the answer's own budget_seconds, if sooner; each read is handed what is left, at most 5 seconds, and is not retried at that clamp); every cap that cut something is stated, and a lookup a cap cut is read_capped, never no_records. The reads go through the shared t2_index_write transport, and an RDR named twice (by id and by tumbler) is read once. The tenant comes from the bearer of the catalog and T2 clients, so a source in another tenant does not resolve and its T2 project reads empty. The structured envelope gains lifecycle_stream (see docs/cli-reference.md), named_sources[].rdr_number is new, and text mode adds an [evidence request: ...] line for a project that could not be determined, a failed or empty read, or a cut. The single_query fast path reads no records.
  • nx_answer reports what each stream and the reducer received, and checks the named source and the scope (nexus-jypmj.1.8, RDR-232 Phase 1). The structured envelope has a new, always-present evidence_diagnostics key (None when no retrieval ran) and one log event, nx_answer_evidence_diagnostics, with the same counts and no question, label or text. It holds per-stream reference counts by collection (vector, graph, primary, T2), the reducer input counted by stream and collection (the runner records the list the first evidence-consuming operator was handed; a reserved item carries its stream and collection, any other item is attributed by the hash of its text against the ids the retrieval steps returned, and what matches nothing is counted unattributed), a source check (covered, partial or missing per named source, where reserved chunks an intermediate rank or filter dropped before the final reduction make it partial or missing, plus a lifecycle facet in which no_records, project_undetermined, read_capped, read_failed and over_cap are unresolved, not absent) and a scope check (satisfied, widened, conflict or violated, and not_evaluated where it cannot say: a catalog subtree, no item examined, an item or stream reference with no collection, or no reducer input recorded; a traversal whose scope_conflict the scope caused is a conflict), the steps skipped for a scope conflict (they still have no step record or telemetry row; step_count counts the run's step outputs, so it includes their marker outputs, and steps holds the executed ones), a summary per traversal, and the count of traversals that hit the engine's 500-node cap. The log event is emitted for every plan run whether or not structured is set (a text-mode single-query fast path run has no per-chunk output and logs none). Nothing is persisted before Phase 3. The named-source resolution, the owner-name lookup and the primary read run in a worker thread bounded by PrimaryEvidenceCaps.max_seconds (20 s) and the call's own budget_seconds; a hit is disclosed (stopped at the deadline) and the answer proceeds without that stream. A failed primary read is disclosed (primary_evidence.error) instead of silent. A comma-list scope that nx_answer runs unscoped is now disclosed in the text and in a new always-present scope_normalized envelope key, and the scope check says not_evaluated. A planner-made plan whose own retrieval found nothing is not grown even when the reserved stream fed the answer. A resolved source whose vector and graph streams found nothing is now answered from its primary chunks and T2 records instead of being replaced by the no-match notice, because the empty-retrieval guard counts a merged reserved stream as evidence (the continuation handoff's empty-evidence check does the same). The RDR-084 golden fixture now has distractor RDRs beyond the search limit, 62 secondary documents with three link roles (the traversal cap keeps every citation and implementation and only 8 of 20 related), and runs nx_answer itself against the substrate for the lookup plan, the taxonomy-domain builtin and the single_query fast path. The envelope keys are documented in docs/cli-reference.md.
  • nx_answer merges the reserved primary evidence and records the reducer input when the plan passes the evidence in the operator's own arg (nexus-jypmj.1.20, RDR-232 Phase 1; found by the live canary). The inline planner's documented Pattern A, summarize(content=$stepN.contents) (also generate(context=...) as the first reducer, and rank/compare/filter/check/groupby items), resolves to a list of fetched texts in the operator's target arg, and reached neither the merge nor the reducer-input record, which fired only for ids, explicit inputs and extract. On such a plan-miss run the stream was read and never merged (reserved_stream.merged false, source check missing) and evidence_diagnostics.reducer_input was null. The runner now treats a list of strings in the target arg like an explicit inputs list: reserved items first, the input cap on the merged list, the record of what the operator received; a string arg (a prior $stepN.summary) and a deferred bundle ref are not evidence and are left alone, and a later operator still does not merge again. A run with no reserved stream hands the operator the same bytes as before; the one change is that reducer_input is now recorded for these shapes.
  • A note keeps its own tags, category, ttl_days and source_agent in its catalog entry's meta (nexus-6pfgb.5; client change, works on every engine). Two notes with identical text share one T3 chunk row, and the row carries only the last writer's four values, so a note's own were unrecoverable once another note with the same text was stored. Every note put (nx store put, MCP store_put, nx memory promote, a recovery import) now writes them to the note's catalog entry in the same catalog write that stamps meta.doc_id (an update, or the register that mints the entry) (the engine merges meta, so no engine change is needed). A key is sent only with a real value; a re-put under the same title replaces them, overwriting a key it leaves unset that the entry held with a retraction value (ttl_days to 0, the others to the empty string; catalog update cannot delete a meta key, 0 is not an accepted ttl_days input, and a reader must treat 0 and absent alike as permanent), and a put that does not land restores the old values with the old doc_id stamp unless a newer put of the same content has since written different values for the keys this put wrote. A recovery import writes the values its bundle carries (the chunk row's, until nexus-6pfgb.14) and source_agent recovery-import. Nothing reads the keys yet; the readers (store_get/store_list, nx store expire, the recovery bundle, the contradiction flag) follow under nexus-6pfgb. Tests: tests/test_6pfgb5_note_meta_homes.py, tests/integration/test_6pfgb5_note_meta_homes.py.

Removed

  • The subagent git-state integrity check is removed (nexus-i93tz; Sam's decision of 2026-10-25). The conexus hook subagent_git_integrity_check.py (report-only since 7.83.1: a snapshot of the primary checkout's config, hooks and checked-out refs before each subagent Bash call, diffed after) and its three hooks.json registrations are gone, and the planned second phase, which would have put changed config and hooks back, is never built. Three and a half days of real subagent work gave 188 rows and no change report, so the check had caught nothing and had no false alarm to judge. The subagent git-write guard (subagent_git_write_requires_orchestrator.py) keeps its logic and is the only line left; it is now frozen except for real incidents, and its docstring and conexus/hooks/scripts/routing/README.md list the routes it does not cover (raw writes into .git/config and .git/hooks, index, ref and HEAD writers through plumbing, verbs held in $VAR, decode-then-exec, write-a-script-then-run-it) as known and accepted. Rows the hook already wrote to a routing store (integrity_info, integrity_unverified, integrity_change) still read: nx hook routing-stats counts those outcomes under extra, and once the plugin copy that registers the hook is replaced it marks the rule (unregistered) (pinned by tests/test_hook_routing_stats.py against the new hooks.json; an install still on a 7.83.x to 7.86.x plugin copy shows it registered until it upgrades). The hook's own pruning went with it, so nx-hook self-gc (the SessionStart sweep) now removes git-integrity/ session directories under the nexus config directory that have been untouched for two days, the bound the hook used, because installed 7.83.x to 7.86.x plugin copies keep writing there; a live session's directory is never that old. The test-run allowlist entry for that directory (tests/conftest.py) stays while the pinned plugin still registers the hook, since sessions on those copies keep writing it during any unit run they launch; tests/test_real_config_dir_mutation_guard.py fails with a delete instruction once the conexus ref pinned in .claude-plugin/marketplace.json is a tag whose hooks.json no longer registers it (it skips with a stated reason where the pinned tag cannot be resolved locally, and fails there under NX_REQUIRE_PLUGIN_DRIFT_CHECK=1). The plugin side goes live at the next conexus cut (conexus/PENDING_RELEASE.md).
  • The anchored plugin channel (plugin-vX.Y.Z-n cuts, RDR-197) is retired (nexus-rbmja.8; Sam's decision of 2026-10-26). Its cuts never reached installed users: Claude Code's plugin update keys on the version field, which an anchored cut leaves unchanged (nexus-konsk, nexus-semdv), and its rehearsal broke on main's code under develop's tests. scripts/cut_plugin_release.py, scripts/plugin_cut_back_merge.sh, scripts/check_cut_ledger_clean.py, .github/workflows/plugin-release.yml, tests/e2e/plugin-cut-rehearsal/ and their tests are deleted; tests/test_plugin_channel_retired.py keeps them gone. Plugin-only content now ships as a patch release off the last client tag: the release skill's "Plugin-only fast lane" section, gated by scripts/check_plugin_only_eligibility.py, tests/e2e/release-battery.sh --plugin-only vX.Y.Z and the post-publish tests/e2e/plugin-delivery-proof.sh. RDR-197 is superseded and stays as the record.

Fixed

  • The indexer registers no document for a whitespace-only text file, and .drawio is skipped (nexus-5j8fp, client change; works on every engine). A 1-2 byte file such as ghidra's Module.manifest (classified as prose) or serena's __init__.py passed the zero-byte guard, registered, and then failed or never completed its fence on every run: 11 of the unfenced zero-chunk documents in the cloud tenant (4 of the 107 NULL-fence and 7 of the 157 failed zero-chunk documents; the serena __init__.py rows are NULL-fence, the ghidra Module.manifest rows are failed). Repo discovery, the RDR walk and nx index md/rdr now refuse a non-empty file of at most 4 KB whose text is empty after str.strip(): the repo walk skips it silently (counted in skipped_unchunkable) and the single-file forms fail loud with UnchunkableContentError. A document an earlier run registered for a still-present whitespace-only file is tombstoned in the same run that skips it, on a full run for every such file and on --since-head for a file the commit changed (an untouched blank file in a hook-only repo waits for a full run or for reconcile-stale), through the path a git deletion takes (its chunks follow at nx catalog purge-trash); housekeeping's two-run miss-count sweep stays the fallback, and nx catalog reconcile-stale --execute tombstone-zero-content retires the rows of an install that does not re-index. nx catalog verify and reconcile-stale classify such a source zero_content_by_design and describe the bucket as zero-byte, binary or whitespace-only, so a census no longer prescribes a re-index for it. The fail-closed fence is unchanged: no document completes at zero chunks. .drawio (draw.io XML, which fell through to prose and chunked as noise) joins the skip extensions: existing .drawio documents are tombstoned by housekeeping's sweep (two full runs) once the file leaves discovery, and their chunks follow at nx catalog purge-trash. Tests: tests/integration/test_5j8fp_whitespace_only_never_registers.py, tests/test_classifier.py, tests/test_doc_indexer.py, tests/test_catalog_reconcile_stale.py.
  • nx rdr preamble rdr-create no longer offers an RDR number another branch, worktree or T2 record already holds (nexus-3piej). Next ID came from the current tree's RDR directory only, so a number on an unmerged branch or in another worktree's uncommitted draft was offered again, and the rdr-create skill's record write then overwrote that RDR's T2 record (RDR-233, 2026-10-26). The preamble now takes the highest number across every ref (git log --all), every worktree's RDR directory and the {repo}_rdr T2 titles, and lists the numbers it skipped with their holders; the skill reads the record before writing it and stops when the number belongs to another RDR.
  • nx taxonomy hubs --warn-stale flags a hub when a contributing collection was discovered after the hub's latest projection, as RDR-077 specifies (nexus-9kp10; Sam's ruling 2026-10-26; client fix, engine change in the next engine cut). It read the reverse since RDR-077 shipped: it flagged hubs projected after the last discover, the normal discover-then-project order, and missed hubs a later rebuild had left behind. The client now derives is_stale from the route's last_assigned_at, max_last_discover_at and never_discovered_count, comparing parsed timestamps, so the warning is right against any engine; the engine's own is_stale flips in engine-service-v0.1.165. The STALE line names max_last_discover_at > last_assigned_at.
  • A saved plan with a $stepN.field inside other text or an object is refused at save, skipped by the matcher and reported by nx plan hygiene; a plan-miss planner is asked once to fix it (nexus-jypmj.4, follow-up to nexus-jypmj.1.19). The run-time scan (runner.find_embedded_step_ref, the whole-plan form of the same raw-arg check) now also runs in plan_save (refused, nothing written), in plan_match on both the cosine and the text path (the plan is excluded and a plan_match_embedded_step_ref_excluded record names it; the row is kept), and in nx plan hygiene (flagged, disabled by --apply). On a plan miss nx_answer scans the planner's output before dispatch; a plan that fails is sent back once with the error text appended to the same prompt, and the second planner call's cost joins the run's planner cost. A retry that fails (a timeout, unusable output, an empty plan) keeps the first plan, so plan_run still refuses it by name; the error text appended to the prompt is capped at 200 characters. The seed loader refuses a template holding such a ref, by name, in its errors. A second bad plan goes to plan_run, which refuses it as before. Also: the single-query fast path refuses a PlanRunStepRefError like an unresolved $var instead of falling to the generic handler; a list evidence arg that mixes fetched texts with chunk ids ([$step3.contents, $step5.ids]) drops the ids with an evidence_step_ids_dropped warning (count and a truncated sample) on all three branches that send evidence to a reducer (Pattern A, the inputs alias, extract(inputs=[...])), since a flat list has lost the paired collections that would hydrate them; the runner marks the strings that came out of a .ids or .tumblers ref, so fetched text that reads like an id (1.2.3) is kept; the nx_answer envelope comment on cost_usd is corrected. tests/test_rdr232_planner_reference_census.py ties every evidence arg _PLANNER_TOOL_REFERENCE documents to a reserved-stream merge site in _hydrate_operator_args. nx answer-runs keeps its one-sided allowance for the planner's share of a plan-miss run's cost: bounding it needs planner_cost_usd stored on the run, a schema change not made here.
  • A plan-miss nx_answer run's cost_usd includes the inline planner's own dispatch (nexus-jypmj.1.20, found by the RDR-232 canary). The planner's cost was captured in an ambient usage sink that only the budget check read, so the structured envelope's cost_usd and the stored nx_answer_runs.cost_usd summed the plan's steps alone and a plan-miss run's cost was a lower bound. Both now add the planner's known cost (one helper, so they agree); a plan-match hit has no planner dispatch and is unchanged, and a run with no known cost still reports None. steps[].cost_usd is unchanged, so on a plan-miss run cost_usd now exceeds the step sum by the planner's cost. nx answer-runs --steps accounts for that: its cost-consistency check accepts a run cost at or above the step sum on an inline-planned run (plan_id 0) and still flags one below it, or any mismatch on a library plan.
  • A plan step that puts a $stepN.field inside other text or an object now fails before it dispatches, instead of handing the operator the literal token (nexus-jypmj.1.19; found by the RDR-232 canary, older than Phase 1). The runner substitutes a reference only when it is the whole string and never looks inside a dict, so an inline-planned generate(context="Code: $step3.contents ... $step5.ids") (or a dict of refs) reached the model as placeholders, which answered that no source material had arrived, and the run still reported answered. plan_run now scans every step's raw args before the first dispatch, and _resolve_args scans again for the continuation composers, and raises PlanRunStepRefError naming the step and the arg; a list of whole-value references still works. The scan reads the plan's own args, so a question that quotes $step1.ids through $intent is untouched. The planner's tool reference now says an argument is one reference or a list of them and shows context=[$step3.contents, $step4.contents] for several sources. A plan already saved with an embedded reference now errors where it used to run with the placeholder text.
  • The provenance line on hydrated text and a search hit's age line name each document's own title, indexed date and year, from the catalog, and an expiry once the catalog records one (nexus-6pfgb.11, client change; works on every engine). A T3 chunk row is one per chunk text, so the title, indexed_at, bib_year and ttl_days on it are the last writer's. The [source: title . collection . indexed D . published Y . expires] line that store_get_many(structured=True) returns in source_notes (which the plan runner prefixes onto the text an operator prompt reads), and the per-hit age line in the search text render, printed those row values, so a chunk two documents share named the same document and date for both. Now: hydrating under a document's tumbler (the plan runner's metadata-scoped, graph-hop and aspect-scoped paths) builds the note from that document's own catalog entry; hydrating a bare chunk id reads the live documents that hold the chunk in the collection it was found in (chash_positions, shared with the section-type read, then one resolve_many for the batch) and keeps each of title, indexed date, year and expiry only when every owner states the same value. When owners disagree on a date, year or expiry, or one lacks it, that field is left out and the chunk row's value is never used; when owners disagree on the title, the note lists their titles, quoted and comma-joined (up to three, then "+N more"), and counts owners with no title ("N untitled") so the line cannot read as one document; the search render's display title still joins titles with " · ". When more than ten documents hold the chunk, when an owner the manifest names is not returned by resolve_many (so agreement cannot be checked), or when none resolves (a non-chunk-hash id, a chunk with no manifest row, a catalog that cannot answer), the note names the collection only. A search hit states its age only when every owner of the hit has resolved and those in its collection agree. The year is the catalog's bib_year, else its year; an enriched paper whose catalog has neither (the enrichment year sat only on the chunk row) therefore loses "published Y", as the row's value is no longer a fallback. The [source: ...] instruction an operator prompt carries no longer promises a document and dates on every line. A search hit takes its owners from the lookup that already resolves its display title (_resolve_display_maps now also returns the owners' dates, year and expiry from the same entries), so the page adds no catalog call. An expiry shows only for a document whose catalog meta carries ttl_days (the notes catalog-meta change, nexus-6pfgb.5, lands that); until then a note shows none. nx search --json does not print the new working key _owner_facts. store_get_many computes source_notes only in structured=True, which is the only mode that returns them, and the plan runner's tumbler hydration and the nx_tidy prefetch, which never read them, skip the owner resolve_many behind them. Tests: tests/test_6pfgb11_provenance_from_catalog.py, tests/test_context_annotations.py.
  • "Is this file already indexed" is answered from the catalog document, not from a shared chunk row's content_hash (nexus-3ajpd). A T3 chunk row is one per (collection, chunk text), and every write merges its metadata last-writer-wins, so content_hash on a row is the file hash of whichever document wrote that text last. The skip in nx index md / pdf / rdr matched a row on that hash, so a document whose rows another document had stamped last was judged changed (a single-chunk document sharing its chunk could re-index on every run), and a changed document with no fence (or, through the uncached check_staleness, any changed document) was judged current when another document holding the same text had stamped a row with the new hash. Now a document is current when its index fence is complete at the file's hash and one chunk of its own manifest is present with the target embedding model; nx index repo builds its staleness cache the same way (the cache records presence and model, the catalog fence supplies the hash), and the uncached check_staleness reads the fence instead of matching a hash collection-wide. One re-index per NULL-fence document (Sam's ruling 2026-10-26, T2 nexus/5xn3k-design-amendment-2026-10-26): only a document with a NULL fence (indexed before the fence existed) used to skip on a matching chunk row; a failed or indexing fence was already stale, and a pre-fence engine is below the engine floor. A NULL-fence document is now stale. A file-backed document that yields chunks is re-indexed once on the next index run of its source, the completion records the fence at the file's hash, and the run after that skips it. A document that yields no chunks has no chunk of its own for the skip to find, so it re-indexes on every run, as it did before (not changed here). A note or other non-repo document has no source file in an index walk, so it re-indexes only when its source is indexed again. The engine skips re-embedding chunks it already holds, so the one-time cost is extraction, chunking and upload, not embedding (a PDF or paper is re-extracted). A fence that cannot be read now re-indexes the document for that run instead of skipping it (the read failure is logged at WARNING). nx doctor's "stale index-run fences" row lists only documents indexed after the install's fence baseline; the older NULL-fence population is not listed, but every branch of the row counts it and states the same scope as above. nx catalog reconcile-fences stamps non-repo documents with a digest key (the chunk's chash for a one-chunk document, a digest over the manifest chashes otherwise), so the next real index of a multi-chunk document sees it changed once and re-records the real value. Two reads that named a document by its chunk's hash changed with it: nx enrich bib looks a title group up under the title of the one document that owns all of its chunks (by the catalog manifest) and falls back to the chunk title when that is not exactly one document, where it used to name whichever document wrote a shared chunk last; and an .nxexp export now carries key_registered per owner (the document's registration recorded the same hash as its fence), which lets an import keep a document's content key when every chunk it holds was restamped by another document (a bundle without the field behaves as before; a PDF or note registers without a file hash, so for those a chunk carrying the key is still the only evidence). Stale docstrings that said a chunk keeps its first writer's hash and is never rewritten are corrected. Not changed here: the streaming PDF post-pass and the engine's quarantine reattach version check still select or compare by a chunk row's content_hash (link-existing still takes the row's content_hash as a candidate key; see nexus-6pfgb.22).
  • nx index repo --since-head skips a delta file the catalog already holds at its current hash (nexus-a3rl7). The delta is the files that differ from the base commit, not from the last index, so a base that did not advance, or a file that changed and changed back, names files an earlier run already indexed. The delta path handed the staleness check an empty cache, so each of those went through chunking, upload and the fence again (the engine skips embedding chunks it already holds, nothing else). It now builds the cache for the delta's own documents from the catalog: the fence hash from the registration pass and the model from one chunk of each document's own manifest, in one batched manifest read and batched chunk reads for the whole delta, with no sweep of the collection. A document with no complete fence, no chunk in T3 or another model is indexed as before, and a failed or indexing fence still forces a re-index.
  • The manifest heal marks a lost manifest-less document for re-index (nexus-v9xwj, follow-up to nexus-8o06q). A document with no manifest rows and a chunk_count above zero that the heal cannot rebuild (T3 holds no chunk under its content hash, no content hash or collection is recorded, or its collection does not exist) was only listed LOST, and its fence stayed complete. nx catalog reconcile and the nx index repo self-heal now fail its fence the way they do for a gapped document with rows, and the reconcile LOST line says (marked for re-index); --dry-run says would be marked and writes nothing. An index run that walks a file already re-indexes a document whose manifest is empty when none of its T3 chunks carries a doc_id metadata key (every chunk written since RDR-108 Phase 3; nexus-3ajpd), so for that document the mark changes what the fence says, which is what the PDF early probe in nx index repo (it decides from the fence alone) and nx t3 gc read, not whether the next nx index repo indexes the file; a pre-Phase-3 chunk that still carries a doc_id metadata key puts the document in the staleness cache by that key, and then the mark is what makes the run re-index it. Not marked: a document whose T3 fetch failed or whose collection lookup failed for a service error (the collection listing raised; listed LOST, nothing written; HttpVectorClient.get_collection used to read that failure as an empty listing and report the collection absent, so an outage could have marked), and a never-chunked document. HttpVectorClient.get_collection now lists strictly, so on an outage it raises CollectionNotFoundError with the message service unavailable checking collection X, chained from the VectorServiceError, where it said collection X not found in service, and it logs http_vector_get_collection_service_unavailable as a warning (the swallowed listing logged http_vector_list_collections_failed); the exception type is unchanged, so every caller that catches it behaves as before. On a pre-catalog-005 engine, a strict listing whose /collections fallback call fails now raises instead of returning an empty listing. When the heal stands down for a lost document (a run landed its rows, moved its chunk_count to 0, or holds its fence at indexing), or could not look for its chunks, nx catalog reconcile prints the reason on the LOST line: (not marked: ...). A chunk that a document shares with a document that wrote it last carries the other document's hash, so such a document can read LOST while its chunks exist; re-indexing its source restores its rows. A marked document makes nx t3 gc refuse its collection unless --allow-incomplete-index-state is passed.
  • nx catalog orphan-backfill link-existing links only chunks no live manifest of the collection names (nexus-6pfgb.22). A chunk row is shared by every document holding that text and its title and content_hash are the last writer's, so --by title and --by content_hash appended a whole group to one document: chunks the document already owned (a second manifest row for the same chunk, at a new position) and chunks another document owned, while a chunk the document owned that another writer had restamped was missed (or went to the other writer). Now a candidate chunk is linked only when no live document of the collection names it (the engine's chash-positions route, filtered to the collection by the collection each row carries on engines newer than engine-service-v0.1.164; v0.1.164 and earlier send no collection, so they use the referencing documents' manifests, filtered the same way); one the target document already holds (under any collection value of its manifest rows), and one another live document of the collection owns, are skipped and counted. New rows go after the highest position the target holds, not after its row count (a manifest can keep a gap, and an append upserts by position, so the old start could overwrite a row the target holds). Two live documents with one title: that title's group is skipped and counted, as a content hash two documents share already was, and is not handed to --also-synthetic. --also-synthetic no longer registers a chunk a live document of the collection already names: it counts them and registers no document for a group left empty. The direct orphan-backfill synthetic verb is unchanged: it can be run again, a synthetic document's URI is the same each time and a manifest write replaces the document's rows, so leaving out the chunks an earlier run registered would drop them. Not guarded: a chunk whose only owner is a document in the trash, and a chunk an earlier version of the target left behind that nothing names, are manifest-less and ARE linked (the first is pinned in tests/integration/test_6pfgb22_link_existing_manifest_less.py); the candidate target is still picked from the chunk row's last-writer title or hash, so a match is only a candidate. The ownership lookup and the writes are not atomic: a document registering between them is not seen. --dry-run runs the same selection and prints "Would link N chunks across M existing Documents" with the skip counts (link counts only: it stops before the --also-synthetic step and does not count the chunks that step would leave alone), where it used to print only the number of documents with a title and the collection's chunk count. The --by content_hash help and output no longer say head_hash: the key is the document's file-content hash (index_content_hash, else meta.content_hash), as it has been since nexus-lhmu1, and a hash two documents share matches neither. A lookup that fails links (and registers) nothing. Client-only; works on engine 0.1.164.
  • A second substrate-heavy pytest run queues behind the first instead of exiting 75, and a Mac laptop refuses a parallel substrate run (nexus-tvt6u). The suite lease used to refuse at once unless NX_SUITE_LEASE_WAIT=1, so ordinary contention between sessions sharing one box became red runs. It now waits up to 30 minutes (DEFAULT_WAIT_SECONDS), prints suite lease: waiting for <holder>; waited N s of M s at once and every minute (so scripts/watched_pytest.py does not kill a queued run as stalled), and starts when the holder exits; a wait that runs out exits 75 naming the holder. NX_SUITE_LEASE_WAIT=0 keeps the immediate exit, =1 is the same as the default, and NX_SUITE_LEASE_UNGUARDED=1 still runs on at once. Inside a lab-run grant (LAB_CPUS or LAB_MEM_MB) on Linux the lease is skipped, since lab-run is the queue there (it admits by CPU and memory, so two lab-l suites can run together on chas); on macOS a lab-mac grant is admission only and the lease is still taken, which tests/e2e/macos-guest/host.sh guard and hold rely on. New in tests/conftest.py: on macOS with no grant, an xdist run with more than one effective worker whose NX_TEST_T2_SUBSTRATE is not none exits 75 with lab-remote -c lab-l -- uv run pytest <args> (NX_LOCAL_HEAVY=1 opts out); serial, -n 1 and NX_TEST_T2_SUBSTRATE=none runs are not refused. tests/e2e/release-preflight.sh reports a pytest leg stopped by the lease as suite lease held by <holder> (matched at line start) instead of "no verdict line matched", and its pytest legs and scripts/plugin_cut_back_merge.sh's default verify now run with NX_TEST_T2_SUBSTRATE=none (none needs the engine) so they never queue on the lease. Tests: tests/test_suite_lease.py, tests/test_laptop_heavy_run_guard.py, tests/test_release_preflight_lease_detail.py.
  • A cross-collection topic assignment no longer lands on a topic of a superseded collection (nexus-2k8wi; engine change, in the next engine cut). A supersede tombstone, the retired source of a re-embed cutover, keeps its taxonomy centroids, and a rename tombstone's are re-homed, so only the first is affected. The cross pass took the nearest centroid of any other collection, so a live collection's chunks could attach to the retired collection's topics and raise their doc_count. Three things change. (1) The server-side cross pass (assign_from_chashes, cross_preview), the cross-collection centroid query and the foreign-centroid read (GET /v1/taxonomy/centroids/foreign) skip any collection whose registry row names a successor. That read also feeds nx doctor --check-assignments and the cross-link pass of nx taxonomy discover (compute_cross_links, which writes topic_links), so a retired collection's topics get no new cross-links; existing links stay. (2) nx taxonomy project --persist (and --backfill --persist, and any other client posting assigned_by=projection rows) reads each target's centroids by name and names the topic itself, so the engine now SKIPS a projection row whose topic belongs to a superseded collection, in POST /v1/taxonomy/assignments/assign_many and /assign, instead of writing it. Skipped, not refused, because one persist call spans every target and a refusal would roll back the live targets' rows with the retired one's. assign_many answers {"persisted": N, "skipped_superseded": M} (N counts rows written) and assign adds "skipped_superseded": true; centroid, manual and import writes are unchanged. (3) The CLI leaves superseded collections out of the default targets of nx taxonomy project and out of --backfill (one /catalog/collections/list read; the engine guard does not depend on it, so an older engine or a failed read only costs the extra rows). Reading a tombstone's own centroids by name is unchanged, and assignments already written are not rewritten. Liquibase vectors-038-1, function definitions only.
  • The projection commands report the rows the engine wrote and the rows it skipped as superseded, and discover and index leave superseded collections out of their projection targets (nexus-h2v0l, follow-up to nexus-2k8wi; client change, works on every engine). nx taxonomy project --persist, --backfill --persist, the projection pass of nx taxonomy discover and the one in nx index repo counted the rows they sent, so they said "Persisted N" for rows an engine with the nexus-2k8wi guard had skipped. They now sum the persisted and skipped_superseded answers of each assign_many page: Persisted N projection assignment(s), skipped M (superseded targets). The skip clause appears only when M is above zero, and an engine that sends no skipped_superseded (engine-service-v0.1.164 and earlier) prints what it printed before. HttpTaxonomyStore.persist_assignments now returns the rows written; the new persist_assignments_counted returns (written, skipped_superseded). Discover's projection pass and the index pass also drop superseded collections from their sources and targets, as project and --backfill already did, so a retired collection's centroid can no longer win a chunk's single best pick and cost it the live runner-up. Tests: tests/test_taxonomy_projection_skip_count.py, tests/db/test_http_taxonomy_batch.py; the engine test now also pins that centroid and manual rows onto a retired topic still land.
  • section_type, section_title and page_number follow the document, not the shared chunk (nexus-tko2w, engine change with a client half). A chunk row is one per chunk text, so when two documents held the same text the labels on it were whichever document wrote last: --where section_type!=references could keep a references chunk or drop a body chunk (RDR-232 research 7). The three labels are now columns of the catalog manifest (catalog_document_chunks, changeset catalog-046, nullable, no backfill: a re-index of a document fills them), written with every manifest row, together with a labels_written marker that says whether a row's labels are its document's own answer. A --where on them is answered per (document, position): a chunk several documents hold comes back once for each document whose label passes, with that document's doc_id and labels, and the limit counts after that (on /hybrid-search each of those rows also carries its own document's source_uri, and the clustered MCP search keeps every one of them). An unlabelled chunk, an empty label included, is kept by != and dropped by = (except for an explicit empty-string operand, which names the unlabelled value: ="" matches it). A row whose document wrote its labels is authoritative: the shared chunk row's label, which is the last writer's, is never consulted for it, so a new document's chunk with no label does not inherit a sibling's references. store_get_many's section_types reads the owning documents' labels (empty when they disagree or when written and unwritten owners mix), and the plan runner's import-only exclusion reads the document's own manifest row. Documents indexed before this (their rows are not labels_written), and the manifest backfill's rows, keep reading the chunk row's label until re-indexed; a backfill from the chunk row for chunks with a single owner is a follow-up. The MCP query tool's plain path still shows a chunk shared by several documents once; the other owning documents are not listed. The search is cut at four times the limit before the filter runs, so a positive filter on a rare label can return fewer results than asked for.
  • --where on source_author, indexed_at and bib_* follows the document, not the shared chunk (nexus-6pfgb.24, engine change, in the next engine cut; no client change, a client on engine 0.1.164 sends the same request and gets the chunk row's answer). Same defect and same remedy as the section labels above: a chunk row is one per chunk text and its metadata is the last writer's, so --where bib_year>=2020 or --where source_author=Alice could return a chunk for a document that does not satisfy it. The engine now answers these per owning document from the catalog and returns a chunk once for each owner that passes, with that owner's doc_id, position and, on the filtered keys, that owner's value. source_author and indexed_at read the document's author and index time (the chunk row's value when the author is empty or there is no index time); the bib_* keys read the document's bibliographic columns and are authoritative, so an unenriched document has no bib_year and the shared row's is never read. bib_* answers only for documents enriched through nx enrich (it fills those catalog columns); nx index pdf --enrich writes bib_* on the chunk rows only, so a document enriched that way reads as unenriched on this engine and bib_year>=2020 drops it. bib_year, bib_citation_count and page_number are numbers, and an equality (=, !=, in, not in) accepts a number's text (a range with a text operand matches nothing, as on the chunk row); every other answered key compares as an exact string (bib_semantic_scholar_id 0042 is not 42). An explicit empty-string operand matches an empty or missing value (="", in [""]) or drops it (!="", not in [""]); the chunk-row filter matched only a stored "", so a missing value now also matches "" and is dropped by !="" and not in [""]; the nx client refuses an empty value, so only a raw HTTP caller can send it. The catalog metadata keys (tags, category, ttl_days, source_agent, extraction_method, quality_gate_overridden, extraction_source, frecency_score) are not part of this change: no indexer writes them on the catalog document yet, so they stay filters on the chunk row, inside the search, as before. No new changeset. The search is cut at four times the limit before the filter runs (at most 1200), so a positive filter on a rare value, bib_venue=NeurIPS in a collection where few documents carry it, can return fewer results than asked for where the in-index scan kept walking. title= and content_type= are not part of this change, and search_metadata_scoped, search_aspect_scoped, search_graph_hop and the engine's get routes still read the chunk row.
  • A re-embed resume, and the kept_tombstoned re-entry, are refused with the missing piece named when the engine cannot embed for the target's model (nexus-6zchp, follow-up to nexus-q6t89; engine change, local engine only). A start already refused this; a resume with the target present, and the re-entry, skipped the check and ran into another failed batch. A resume whose target is gone is unchanged. Held off 7.86.2: the change is in the engine, and engine-service-v0.1.164 does not carry it; it ships with the next engine tag.
  • A data token that is due for refresh adopts a sibling's fresh lease instead of minting, so a reboot no longer sets off a 429 burst, and a failed refresh keeps the still-valid token (nexus-dncjg, nexus-kcdbx). Processes that had borrowed one lease share its expiry, so after a reboot a dozen of them came due in the same second, minted at once past the engine's burst of 5 per credential and tenant, and every loser kept retrying a mint the limiter refused (the long-lived MCP server kept failing T2 calls for minutes while a fresh nx command worked). The refresh branch of DataTokenManager.bearer_for now reads the lease first and adopts a sibling's remint (a different token that expires later than the cached one); otherwise it mints under the same per-host mint lock as a cold start, so one process per host mints. A refresh that fails while the cached token is still valid makes one mint attempt with no retry sleep (it holds the key lock and the mint lock, and used to stall them for up to 30 seconds), returns that token, and waits a jittered 15-45 seconds before the next mint, at least the server's Retry-After, never past the token's own expiry; a sibling's lease is still adopted on every call, and when a sibling holds the mint lock the refresh waits at most 2 seconds for its lease before returning the cached token. Only a failure after the token has expired raises DataTokenMintError (which now carries status and retry_after), and a 401 or 403 on a refresh (revoked credential, wrong tenant) is logged at error level. Each manager draws its own refresh point once, between 15% and 25% of the TTL remaining, and a manager takes a lease on a cold read only when it is not yet due for its own draw, so an accepted lease is never refreshed on the next call (has_fresh_lease and fresh_lease_token keep the fixed 20% bar). The 1s/2s mint backoff is now full-jitter (a random wait up to those values, so the worst case is unchanged). A filesystem that cannot flock (ENOLCK) now falls back to an unguarded mint on the cold path too. The module docstring that said a refresh never consults the lease is corrected.