Skip to content

v7.88.0

Choose a tag to compare

@github-actions github-actions released this 28 Oct 10:44
· 84 commits to main since this release
5dde6a0

Pairs with engine-service-v0.1.167 (REQUIRED_ENGINE_VERSION raised from 0.1.165; engine-service-v0.1.166 was tagged but never published, its mac-arm64 leg having failed on a build JDK already pruned of the CVE-2026-91403 roots). The engine carries the RDR-231 note-URI backfill note-uri-001-1, a data migration that is IRREVERSIBLE: it rewrites every chroma:// note source_uri to nx-note:// in catalog_documents, document_aspects and document_highlights, keeps no record of the old spelling, and has an empty rollback. Running an engine older than 0.1.167 over backfilled rows is unsupported. If the backfill finds a note stored as live twins in both spellings it halts engine start with nothing changed; the recovery is in docs/migration-runbook.md ("If the RDR-231 note-URI backfill halts startup"). The pre-tag census on a fork of production found no twins.

Added

  • /prose-edit ships as its own opt-in plugin, conexus-prose-edit, and runs in any repository (nexus-scmcs). Install with /plugin install conexus-prose-edit@nexus-plugins, then run /prose-edit setup once per machine and repository: it checks that Nexus and T2 answer, prints the two temp-folder permission rules for ~/.claude/settings.json when they are missing (a plugin cannot ship them), asks which folders hold which genre, and offers to store example passages. A document named by an absolute path, such as one in a git worktree, is now edited in the checkout that holds it; before, it was refused as outside the repository, and the editor read the session checkout's copy. A repository without .claude/skills/site-page gets no site-page layer instead of an error. The skill and the line-editor agent move from .claude/ into prose-edit/, so a nexus checkout needs the plugin installed to run /prose-edit.
  • nx_answer can check whether the evidence covers what was asked before it answers (opt in with NX_ANSWER_COVERAGE=1), returns a partial answer behind an [insufficient-evidence: <n> unresolved] notice, and sends a versioned run summary with every run record (nexus-jypmj.3.1, .3.2, .3.3, .3.5, RDR-232 Phase 3; client change, works on every engine). The coverage check (off by default). With the check on, for every request that reaches a final reduction (request extraction always yields at least one facet, the whole question when nothing finer is certain), the runner assesses the evidence the final reduction will be handed before dispatching it: one operator_check call per evidence stream, at most 6 facets per call, then one targeted repair retrieval for facets nothing held bears on and one reassessment, all inside the call's own deadline and USD budget; the whole assessment stage may use at most 0.4 of the deadline left when it starts, whatever the number of streams and facet groups (nexus.plans.coverage_ledger, nexus.plans.coverage_repair). The calls are step records and cost like any other step; they add latency and spend to every such run. The check is off by default: with NX_ANSWER_COVERAGE unset (or 0, false, off, no) no boundary is handed to the runner, every run is unassessed with the reason switch_off, and nx_answer is exactly what it was before Phase 3. NX_ANSWER_COVERAGE=1 (also true, yes, on) switches it on; a value in neither vocabulary reads as off whatever the default, so a mistyped value never switches the check on, and warns once. The default flips only after the Phase 3 measurement (nexus-jypmj.3.10) has recorded the assessor's tag adherence and accuracy, cost and latency on the golden set, and Sam has approved it. An assessor reply with no usable evidence record is an assessment failure (the facets are unassessed, the answer is generated), never "nothing supports this facet"; a facet with both supporting and contradicting evidence is covered and marked conflicted, and the repair queries stay inside the caller's scope and the plan step's own filters. What a run returns now. complete: an answered result, as before. partial with a usable answer (answered prose with at least one covered facet, or with some facet unassessed): the new answer_shape insufficient_evidence; the text opens [insufficient-evidence: <n> unresolved], names each facet the evidence did not settle and why, then gives the prose. It counts as a success for the plan, so the matcher's always-failing skip does not retire a plan that usually covers most facets, and an inline-planned partial answer is never saved as a plan. unavailable: a failure for the plan, never grown, with the standard non-answer notice and the retrieved chunk references. The new shape uncovered is for evidence that covered no facet (terminal generation is skipped when every facet is known and none is covered, and prose generated anyway is withheld; with an unassessed facet the prose is kept as a partial answer); no_evidence keeps its meaning of nothing reaching the reduction. unassessed (no facets, an assessor failure, the switch off) changes nothing. A run the budget or deadline cut before its terminal reduction records coverage none in its run summary, not unassessed. A generation failure keeps the existing error shape. The single-query fast path has no final reduction and reports coverage none. Each /record and /complete write carries a version-1 run_summary (coverage counts and state, request kind, eligibility, per-stream counts, scope, repair, cost and latency; no question text, and with trace=False no facet text or source hints either), sent only when the engine's /version advertises nx_answer_run_summary_supported (engine-service-v0.1.167 and later). The structured envelope gains a run_summary key: persistence is persisted when a readback matches a digest over canonical JSON, dropped when it does not, unsupported on an engine without the flag (today's deployed engine, and the public edge until it allowlists the flag), unverified on a text call, which reads nothing back, and not_recorded when no record was written; it also shows the returned and persisted coverage states. A summary failure never fails the answer. The structured envelope also gains an always-present top-level coverage object at the state the call returned (requested, covered and residual facet ids, primary-source state, corpus counts and the repair attempt; unassessed with a reason when no boundary ran; ids, states and counts only, source wording blanked with trace=False), the same object a continuation envelope carries as continuation.coverage. The summary's coverage counts and coverage_state are the state the call returned, so a partial ledger whose prose turned out to be no usable answer is recorded unavailable. nx answer-runs. Partial answers are their own insufficient-evidence bucket, apart from executed-ok and degenerate. A row with a run summary is bucketed from it (a successful run with coverage_partial 1), for trace=True and trace=False rows alike; a row without a summary is bucketed from the notice when its text is visible, and a trace=False row without a summary stays redacted. Uncovered and no-evidence rows count as degenerate. The two new answer_shape values are wire-visible, and the nx_answer tool description, the answer_shape and run_summary entries of docs/cli-reference.md and the nx answer-runs help now say so (nexus-jypmj.3.6).
  • The engine stores a versioned run summary on each nx_answer run: nx_answer_runs.run_summary, one nullable JSONB column (nexus-jypmj.3.4, RDR-232 Phase 3; engine half, rides the next engine cut, engine-service-v0.1.167; the client half that fills and reads it is nexus-jypmj.3.5). POST /v1/telemetry/nx_answer_runs/record and /complete accept an optional run_summary JSON object and write it with the run row, so on /complete it commits with the steps and the plan counters in the one RDR-203 transaction (no second write, no follow-up update). The engine checks only that it is an object and that its serialized form is at most 65536 UTF-8 bytes (TelemetryHandler.MAX_RUN_SUMMARY_BYTES); anything else is a 400 naming run_summary, never a truncation. It reads no key of the object, so the client owns its version and its shape. An absent or null field stores SQL NULL, with no backfill and no default. /import and /import_batch carry it (a text-serialised object is accepted), and GET /v1/telemetry/nx_answer_runs/query returns it on every row as an object, null when none was recorded (PostgreSQL normalises key order, so compare canonical JSON). GET /version gains nx_answer_run_summary_supported: true, its own flag. An engine without this change ignores the field and answers 200, so a client must read that flag, not the status, before it believes a summary was kept; the public conexus edge shows the flag only once it allowlists it. Changeset telemetry-016-1 is one ADD COLUMN (migration_ddl_per_worker 2140 to 2141); no new table, so no record-guard, RLS or collection-scope list moved.
  • A note write is refused before it registers or writes anything when the serving engine is older than NOTE_URI_MIN_ENGINE_VERSION (nexus-bivz3.13, RDR-231; client change; the floor is engine-service-v0.1.167: a develop install whose pinned engine is older refuses note writes until its pin reaches the floor). MCP store_put, nx store put, nx memory promote, the recovery-bundle import and the exporter's import_collection ask the serving engine for its version (nexus.db.serving_engine_version) and raise NoteUriEngineTooOldError, with nx daemon restart-stale as the remedy for a local engine and "has not been upgraded yet" for the managed one; when the client's own pin (REQUIRED_ENGINE_VERSION) is below the floor, the message instead says the client is ahead of its pinned engine and note writes resume when the floor engine ships and the pin moves to it (restart-stale converges to the pin and would loop). The exporter's import_collection checks only for a target that can mint a note URI (not docs__, code__ or rdr__), and a recovery-bundle import stops at the first refusal with one error instead of one failure per note. check_engine_release_floor.py --ledger-only (the CI job on every PR to main) and the tag-time forms fail a release whose pin is below the floor; develop never runs them, where the floor leads the pin. A result at or above the floor is cached for the process; a below-floor result is probed again on the next write, so a restart-stale takes effect without restarting the client. Best-effort by design: a probe that finds no version (a dev jar, an unreachable engine) does not refuse and logs one warning per process, and the backfill guard is the backstop. This is the corrected form of RDR-231 Critical Assumption 2, whose "true by construction" holds only when the check fires. Aspect and highlight upserts and reads are not covered (no unique key on source_uri). scripts/build-gate-jar.sh, tests/e2e/local-service-gate.sh and the two gate-jar stamp steps in ci.yml stamp max(REQUIRED_ENGINE_VERSION, NOTE_URI_MIN_ENGINE_VERSION), so the gate jar is never under the floor.
  • nx_answer judges every plan candidate against the request's answer kind, behind a switch that ships off; the envelope reports what it would exclude (nexus-jypmj.2.3, RDR-232 Phase 2; client change, works on every engine). New nexus.plans.eligibility implements the RDR's compatibility matrix over a request kind, a plan's declared kind and its provenance. Provenance is read from the stored row, failing closed: a plan is curated only with the builtin-template tag and a curated declaration; a grown tag or declaration makes it grown; a row that declares nothing is unattested and counts as an untrusted unknown plan, so it serves only lookup and discovery/listing requests, and an unknown request reaches curated synthesis and curated unknown plans only. plan_match takes an optional eligibility filter and asks it of the cosine, the below-floor category-route and the FTS5 candidates after every other drop; the inline planner's few-shot examples are filtered the same way, and _nx_answer_plan_miss is handed the RequestCoverage. The module constant ANSWER_KIND_ELIGIBILITY_ENABLED is False: the filter then only counts, plan_match returns the same plans, bumps the same metrics and asks the cache for the same number of hits, and the planner prompt is unchanged. The structured envelope has a new always-present plan_eligibility key (request kind and decision, enforced, exclusions per branch with reasons, the selected plan's verdict, fallback_reason). A plan the inline planner grows now records the request's kind and route in its declaration when the kind was decided explicit, heuristic or model-assisted, and unknown otherwise. Eligibility honours that recorded kind for no route yet (eligibility.TRUSTED_ROUTES ships empty): every stamp, the caller's explicit hint included, is the request's label and not the shape of the plan's steps (the planner builds its steps without seeing the hint), and the RDR relaxes the unknown-kind restriction for recorded provenance only after review, so until a reviewed decision (nexus-jypmj.2.8) names a route every grown plan is judged as an unknown-kind grown plan (reason unreviewed_route); the route is still recorded at the grow site. A fault while judging a candidate never fails plan_match: it is counted under judge_error, kept when the filter is not enforcing and dropped when it is. Before the switch is turned on, run nx plan reseed: a builtin seeded before nexus-jypmj.2.1 has no declaration until then and reads as undeclared_builtin, which on the golden set takes the enforced run from 5 to 17 inline-planned requests of 24. The 24 golden requests under the shipped (off) setting select exactly what they did; enforcing, the incident request stops selecting the grown clone of plan 473 and wrong-shape admission falls from 4 of 24 to 0 and eligible-plan recall rises from 0.708 to 0.833, both IN-SAMPLE: the request kinds are the first pass of a derivation written with that set in view, so wrong-shape 0 is a floor, recall is biased the same way, and neither figure is a rate. A held-out set is nexus-jypmj.2.8's. A debug-phrased request about named code (Why does nx_answer pick the lookup plan ..., a snake_case subject and a selection or return verb) now derives debug.
  • Plans declare the kind of answer they serve: a versioned answer_kind field in plan_json, set on the 12 builtin templates (nexus-jypmj.2.1, RDR-232 Phase 2; client change, works on every engine). The vocabulary is lookup, synthesis, discovery/listing, comparison, audit, debug and unknown (nexus.plans.answer_kind.PlanAnswerKind, distinct from the terminal-string AnswerShape). A template writes answer_kind: <kind> and the seed loader stores {"version": 1, "kind": ..., "provenance": "curated"} inside plan_json, never in dimensions, so plan identity is unchanged. A missing, malformed, wrongly versioned or unsupported declaration decodes to unknown, and nothing guesses a kind from plan text. nx plan reseed (and the upgrade ladder) rewrites each of the 12 builtin rows in place: same row id, dimensions, description, name, verb, scope and tags, so match and use counters survive; only plan_json gains the field. A plan nx_answer grows from the inline planner is saved with grown provenance and records the request's kind only with recorded route provenance (explicit, heuristic or model-assisted), and unknown otherwise; nexus-jypmj.2.3 passes the request's kind and route at the grow site. Only the seed loader declares curated and only the nx_answer grow site declares grown: plan_save removes any answer_kind key from the plan it is given (its reply says so) and a template's own plan_json may not carry one, so a saved plan declares nothing and decodes unknown. Read paths never retire a row for its declaration: nx plan hygiene leaves a future-version, malformed or unrelated answer_kind alone. nx plan reseed rewrites a row whose description is unchanged in place, so its counters survive; only a changed description re-creates the row. Nothing filters plans by kind yet (matching is unchanged); the session plan cache (answer_kind, answer_provenance, answer_route) and Match.answer_kind carry the declaration for the eligibility step (nexus-jypmj.2.3), and GOLDEN_LABEL_TO_KIND maps the golden-set labels (discovery) onto the plan vocabulary (discovery/listing).
  • nx_answer derives a RequestCoverage before plan matching and reports it; the T2 lifecycle stream is now read only for a request with a gate, lifecycle or status facet (nexus-jypmj.2.2, RDR-232 Phase 2; client change, works on every engine). The new nexus.plans.request_coverage reads the question without a model call: the answer kind (the PlanAnswerKind values lookup, synthesis, discovery/listing, comparison, audit, debug, or unknown when the phrasing does not settle one; verb_infer is not consulted; a bare question word is lookup only for a single-facet question not joined to a second one by and what, and how and the like), the facets that need evidence (f1, f2, ...), the treatment constraints that do not (c1, ...: cite, distinguish ... from ... with a date, acceptance or record cue, do not infer, format), the named sources (the source binding outranks an RDR id in the text), the caller's scope and whether the question explicitly asks about current code (source code, the code, the codebase, the current implementation; the word "source" alone does not). The patterns were written with the 24 golden requests in view, so every figure measured on them (kind agreement, unknown rate, the lists of golden requests that keep or lose the lifecycle stream) is in-sample and is not a rate on requests the author has not seen; a held-out set is nexus-jypmj.2.8's. Every word of the question is in a facet, a constraint or the subject except the joiners it splits on and the listing verb at the head of a clause; where that cannot be promised (a question over 8000 characters, more than 8 constraints, an unreadable clause structure) facets_certain is false and one whole_question facet carries the full, uncut question. The result is the new always-present request_coverage key of the structured envelope, a counts-only request_coverage summary in evidence_diagnostics, and one nx_answer_request_coverage log event (facet text and source hints only with trace=True). It is shadow mode: it does not choose a plan (answer-kind eligibility is nexus-jypmj.2.3), and an RDR named without an owner, project or subtree scope is reported as an ambiguity unless the resolver resolved it to one document. Two behaviour changes: a request with no gate, lifecycle or status facet no longer reads the named RDR's T2 metadata and gate records (the vocabulary is the RDR states and events of rdr-lifecycle.toml plus status, progress, phase, done, state, supersedes, rejected, review and critique of an RDR, and "where ... stand"; with uncertain facets and a bound RDR source the stream is still read; a topic in the subject sentence, as in "Summarize RDR-210 status.", counts; of the golden requests that name an RDR, only G06 and G16 still do), and dimensions={"answer_kind": ...} is taken as the caller's kind hint and removed before plan_match (it would otherwise have filtered out every plan). NX_ANSWER_STRUCTURED_EXTRACT=1 (off by default) lets a first pass that cannot settle the kind or the clauses make one direct schema-closed extraction call, bounded by NX_ANSWER_STRUCTURED_EXTRACT_TIMEOUT (default 20 s, at most 60) and by what is left of the answer's budget_seconds (under one second left, no call: outcome skipped_deadline), and reported in request_coverage.extraction with its own latency and cost. Dispatch is handed a deadline so its own kill path ends the claude -p child, and the model's facets replace the first pass's only when they cover every content word of a question within the 8000-character cap (past it they never replace the full-question facet).
  • An RDR source with no owner, project or subtree scope is qualified by the session's project when that project is reliable, and an RDR id that only the question names is now resolved like a source binding (nexus-jypmj.2.4, RDR-232 Phase 2, assumption A5; client change, works on every engine). Resolution order: an explicit owner/project binding, then a catalog-subtree scope, then the active project (nexus.plans.active_project). scope="rdr" is a corpus and never selects a project. The active project is reliable only when CLAUDE_PROJECT_DIR is set, absolute and an existing directory, the server's working directory resolves to the same directory (the plugin launcher moves a Claude Code server there; a moved or shared server disagrees), and rdr_canonical.current_rdr_owner returns an owner that the catalog has registered. Anything else, a registered project name in the question text that is not the session's, a title in the question that contradicts the inference, a conformant collection-name scope, or a catalog failure leaves the source ambiguous: the candidates of every project are named, and evidence_request.active_project gives the reason (no path appears in it). Where it never applies: Claude Desktop and Cowork start the server in the plugin directory with no CLAUDE_PROJECT_DIR, so on those hosts a number that more than one project holds is always the candidates ambiguity; pass owner or project (an owner name such as ART, or its tumbler) to choose. A reliable session of another repo (a Nexus session asking about ART's RDR-084) still resolves its own project's RDR, marked inferred, when the question gives nothing that contradicts it (RDR-232 section 1: "no reliable active project" is the only case that returns the ambiguity). Two things in the question do contradict it, and each makes the answer the candidates ambiguity: a title the session's document does not fit while another project's does (the incident question's "LIST PARSE Canonical Consolidation" asked from a Nexus session), and a registered project name that is not the session's (ART RDR-084, the nexus RDR-084, matched case-insensitively against the catalog's owner names; a phrase such as "in detail" or "for review" is not a project). If the owner names cannot be read while the question has a word that could name a project, the inference is declined too (active_project.reason says "name check unavailable"): a check that did not run is not a pass. The title is a veto only: it never picks a document, and it counts only when it has at least two distinctive words (not stopwords, not the RDR id), so "in detail" vetoes nothing. An inference the resolver then set aside (the project holds no such number, or its owner could not be checked) is no longer reported as inferred or applied. A source the inference chose among projects that all hold its number keeps a source_inferred line in the request ledger beside the inferred mark. When it applies, evidence_request.owner.status is the new inferred (contribution active_project), the source reports inferred: true, the text says the project was inferred, and an RDR number the session's project does not hold resolves as before with the miss disclosed in the text answer, for an id read from the question as for a binding ("the active project (owner 1.2) holds no RDR-160, so it was not used", plus, on a single-step plan, that the document is not read as evidence), and the source's source_unqualified line stays in the request ledger. A document tumbler as source is never held to the inferred project. An inferred owner qualifies source resolution only: it never narrows the vector or graph streams, which still follow explicit bindings alone. An RDR id that appears only in the question text (the call as the incident filed it: the ART RDR-084 question, scope="rdr", content_type, no source) takes the same path and the same three tiers as a binding. With the session in the ART project it resolves to ART's RDR-084; with no reliable project it returns the candidates ambiguity naming both documents. An explicit source binding wins and replaces the ids read from the text (a malformed source still counts as a binding). A text-derived source is never an owner: evidence_request.source_origin and named_sources.origin (and each source's origin) say question, and only an owner/project binding narrows the vector and graph streams. At most 3 (QUESTION_SOURCE_CAP, a calibration choice nothing has measured) are resolved per request; the rest are named in named_sources.dropped and in a text notice, never silently dropped (a source binding keeps its cap of 6). A range (RDR-078 through RDR-095, also to, until, a dash; the second end may be a bare number after through, thru, until, till or an en or em dash, as in RDR-078 through 095) is not expanded: its two ends are the sources, and named_sources.ranges and a text notice say "range RDR-078 to RDR-095 not expanded: RDR-079 to RDR-094 not resolved". A comparison is not a range: after compare, contrast, difference or versus, RDR-078 to RDR-095 names two documents. For a source read from the question the text notice appears only when something is in doubt (ambiguous, unresolved, inferred over a number another project holds, dropped, or a range); a plain unique resolution (no session project to fall back from) is in the envelope only. A source the caller bound keeps its notices. A resolved text source gets the primary stream, as a binding does. The T2 lifecycle stream still needs a gate, lifecycle or status facet (the fail-open case for uncertain facets stays tied to a bound source). A question that names an RDR therefore now reads the catalog (an owner probe and up to two capped finds per id, in a worker thread, bounded by one budget that the session's-project lookup and the resolution share: PrimaryEvidenceCaps.max_seconds (20 s), or what is left of the answer's budget_seconds if that is shorter), and its envelope gains evidence_request, named_sources and, once resolved, primary_evidence. request_coverage.active_project_tier is now wired.
  • Follow-ups to the RDR-232 Phase 2 reviews: no grown route is trusted yet, the pre-match stages cannot fail an answer, the inline planner is told what the request asks for, and the text scans are bounded (nexus-jypmj.2; client change, works on every engine). (1) eligibility.TRUSTED_ROUTES is empty: a caller's dimensions["answer_kind"] hint no longer mints a plan that the gate would trust for every later request of that kind; every grown plan reads as unknown-kind until nexus-jypmj.2.8 records a reviewed decision. (2) project_name_candidates, the question-source feed, the withdrawal of resolved sources' ambiguities, the coverage log fields, the active-project tier and the fallback reason each degrade to their default if they raise (log: stage name and exception type only), so none can fail nx_answer. (3) The inline planner's prompt now carries the request's answer kind, its facets and its constraints (at most 600 characters, six items each, each clipped; nothing is added when the coverage holds nothing beyond unknown; the prompt is never logged). (4) Title veto: a title in parentheses or double quotes right after an RDR id anywhere in the question (What did RDR-084 (LIST PARSE Canonical Consolidation) decide?, RDR-084 titled "...") is read like the topic-sentence form Synthesize RDR-084, <title>; a parenthesis that names another RDR or opens like an aside (see, e.g., also) is not a title, a title needs two words, and single quotes are not read. (5) The scans for RDR ids, ranges, project names and titles read at most 64,000 characters of a question (a longer one gets a scan_limit ambiguity), the range scan no longer slices the whole prefix per match (it was quadratic: a 750 KB question took seconds), and the envelope lists at most 20 named sources, source hints, per-source ambiguities and dropped ids (evidence_request.sources_omitted, named_sources.dropped_omitted and request_coverage.named_source_count give the rest; the dropped-sources line says and N more). (6) The T2 lifecycle stream fails open for an RDR id read from the question too, when the facets are uncertain. (7) A reliable active-project detection is reused within the process (keyed by CLAUDE_PROJECT_DIR, the working directory and the catalog handle; 10 minutes) and the registered owner names for 60 seconds, so a second RDR-naming call makes no owner lookups. (8) With trace=False the named-source failure logs name a source read from the question as [from the question] and give the exception type only; a bound source is logged as before. (9) An invalid dimensions["answer_kind"] is still ignored and is now recorded as a kind_hint_invalid ambiguity; the dimensions parameter text says discovery/listing. docs/cli-reference.md lists source_ranges among the request_coverage keys.
  • A bare source=RDR-NNN with sibling files resolves to the file whose frontmatter id is that number (nexus-xho6c, RDR-232 Phase 1; Sam's ruling of 2026-10-25; client change, works on every engine). Seven RDR numbers in docs/rdr have sibling files (rdr-200 has nine: the RDR and its phase notes), and rdr_canonical treats each as its own document, so a bare RDR-200 was candidates and the caller had to pass a tumbler. The named-source resolver now asks which sibling carries the id. The catalog document and the chunks do not record it, so it reads the rdr-frontmatter-v1 aspect row (extras.rdr_id) and, where the row does not show the frontmatter was parsed (no row, another extractor, null fields, or empty extras), the file under the registration's file:// source, when this host has it: a .md under a docs/rdr directory (and the owner's own docs/rdr tree when the owner has a repo root), no host part, symlinks resolved first, parsed by the extractor's own parser. The id is compared as a source binding is (RDR-200, rdr-200, 200 are equal; companion-note is not an RDR id; id: 040 is 40). Exactly one carrier resolves, and detail plus an [evidence request: ...] line name the files passed over. Zero carriers, several (RDR-040 has two), or a sibling whose id could not be read stay candidates because an unread sibling might be a second carrier (RDR-232 keeps this as a decision for Sam): detail counts the files by cause (no aspect row yet, a row with no parsed frontmatter, file not readable on this host, an id value that is meant as an RDR id but cannot be read, such as RDR-200 (superseded)) and, where aspect rows are missing or unparsed, names the remedy: nx enrich aspects <rdr collection> for missing rows, with --all for rows that exist but have empty extras. A failing aspect store falls back to the file. The owner qualifier applies first, a document tumbler still wins outright, and a single file asks nothing. New caps max_frontmatter_lookups (24) and frontmatter_call_seconds (5; each read is also clamped to what is left of the answer's budget) on PrimaryEvidenceCaps. See docs/cli-reference.md.
  • The recovery bundle exports each note's own tags, category and ttl_days, and nx catalog import restores them (nexus-6pfgb.14, client change; works on every engine). Two notes with identical text share one T3 chunk row that carries the last writer's keys, so a bundle built from the row gave both notes the same values (the first note's were lost from the backup) and did not carry ttl_days at all. nx catalog export now reads the three keys from each note's catalog entry (nexus-6pfgb.5), and each knowledge_doc record has a ttl_days field (a positive integer or null; the bundle format version is unchanged, an older importer ignores it and an older bundle imports as permanent). Import passes the three values to the note writer, mapping 0, a negative or a non-integer ttl_days to permanent (the catalog stores 0 as its retraction sentinel and put_note raises on it). An entry with none of the note keys (written before nexus-6pfgb.5, or stored with none of them before nexus-6pfgb.15; a put since then always stamps put_at, so a note stored with every key unset has an entry that exports its own empties and is not counted below) takes the chunk row's values only when the row is that note's own write, by the expiry sweep's rule note_ttl.row_written_by (the row's catalog_doc_id, which every note write stamps and a later write by another note replaces, is the note's tumbler and the row's title is the note's; a row with no stamp is nobody's for ttl_days even when the note is the only owner left, because a departed co-owner's values stay on the row, nexus-6pfgb.34, but gives tags and category to a note that is the chunk's sole live owner unless the row's stamp and title both name another note, a wrong tag being cosmetic, nexus-6pfgb.35; an untitled note never matches); otherwise it exports its ttl_days as permanent and its tags and category empty, rather than with another note's, unless it is the chunk's sole live owner and the row's stamp and title do not both name another note, in which case it keeps its tags and category (nexus-6pfgb.35), and the export summary prints note keys not recovered: N for those where the row held a value. Against 7.87.0, which copied the chunk row's tags and category into every record (and no ttl_days), this exports less for some notes on purpose: a note stored by 7.87.0 or an earlier client with none of its keys in the catalog, or brought in by an .nxexp import, whose row carries no writer stamp, another box's stamp, or another title, now exports with its ttl_days permanent (and, unless it is the chunk's sole owner, its tags and category empty) and is named by collection and title in the export output (the first 20), since its row cannot be told from a departed co-owner's. To keep such a note's values, re-put it with its tags, category and TTL using a current client before the export, taking the values from your own records (the row may hold another note's). Import restarts a note's TTL clock: a restored note expires ttl_days after the import (the note writer stamps indexed_at then), re-importing the same bundle restarts it again, a note already past its expiry but not yet reaped is exported and comes back with a full TTL (expiry cannot be judged from the catalog at export), and importing over an existing permanent note of the same title makes it expiring. Pinned by tests/catalog/test_recovery_bundle.py and, against the real engine, tests/integration/test_6pfgb14_recovery_bundle_note_keys.py.
  • Client half of RDR-231: a note's source_uri is nx-note://<collection>/<title> (nexus-bivz3.12, .14, .15; client change, pairs with the engine tag that carries the backfill). uri_for, the aspect and enrich reads, the recovery bundle, the exporter and nx catalog mint and read nx-note://; a chroma:// note URI is still accepted on input (an old export, a recovery bundle, a link) and is rewritten to nx-note:// where the client reads it and by the engine on register and backfill. File-routed chroma:// rows (rdr__, docs__, code__) are residue and are not rewritten to a note. nx enrich list --scheme nx-note lists a note's aspect rows (--scheme chroma now prints a hint to use nx-note; it matches only residue). nx doctor --check-references reads a chroma:// residue row as unknown, never a failure. Against an engine older than the floor, analytic reads match no notes: the aspect operators (aspect_sql and its title lookups) look a note up by its nx-note:// URI, an engine that has not run the backfill still holds chroma:// rows, and the operator reports "does not match" for every note until the engine is upgraded and the backfill has run.
  • --extractor pymupdf (or pdf.extractor: pymupdf) reads a PDF's own text layer with no PDF support installed (nexus-tr1rj.4, RDR-237; client change, works on every engine). Each page is read by PyMuPDF, a core dependency: no layout, no tables, no math, and nothing is installed, started or probed from the PDF stack (docling, MinerU, torch). A page with no text layer is recorded unextracted; a PDF with none on any page fails like an all-scan document on the ladder; a password-protected, zero-page or unreadable PDF fails with a message of its own. The catalog document records extraction_method: pymupdf; auto never picks it. nx index pdf (a file and --dir), nx dt index and nx dt capture take the flag, and all eight stack gates pass the extractor, so a box that cannot install the stack can index PDFs as text. The run summary prints Extractor: pymupdf, text layer only and the pages with no text layer, and it keys on the method the catalog recorded: a pymupdf rerun over a kept buffer a MinerU run left is recorded mineru+degraded and gets the degraded-pages list. Every refusal of a missing stack now names the text-only way out in the words of the command that refused (flag and setting for nx index pdf, the setting for nx index repo and nx collection reindex, the flag for nx dt, none for nx mineru start), including the Windows runtime gap and the install and network failures. This changes existing behaviour: nx index repo and nx collection reindex now read pdf.extractor; before, they always ran auto. A repo configured mineru sends every new or changed PDF to MinerU alone, and one configured docling gets formula-stripped extraction; PDFs already indexed and unchanged are not re-extracted. nx dt index and nx dt capture still read the flag only. Details: docs/cli-reference.md § PDF Extraction Backends, docs/configuration.md
  • nx store get and the MCP store_get list the pages a PDF extraction settled below MinerU's first rung, and an index run lists them at its end (nexus-tr1rj.3, RDR-237; client change, works on every engine). A PDF's catalog record now carries extraction_method and extraction_pages (rung to 1-based pages, {} when every page was clean, so a clean re-extract clears an old map). nx store get and store_get print a Degraded: mineru-no-formula 3,7; pymupdf-text 12 line after Extractor: for a chunk of such a document; a chunk held by several documents gets one line each, labelled by title. nx store get --json gains a degraded_pages list (tumbler, title, extraction_pages) only when some holding document has degraded pages, so scripts need not scrape the text. nx index pdf and nx dt index print Degraded pages in N document(s) with one line per document after their summary; a document that failed or was unchanged is not listed. nx index repo records the same map on the document it registered. A run that cannot record it (no catalog, a refused ephemeral path, a failed write) logs catalog_pdf_extraction_not_recorded naming the file and the reason. After a kept-buffer resume the map covers the pages already in the buffer; a buffered page written by an earlier build names no rung, so such a resume logs resume_provenance_unknown and keeps the method at mineru+degraded instead of claiming a clean mineru. Details: docs/cli-reference.md

Changed

  • nx_answer now enforces the answer-kind eligibility gate and runs the structured request extraction with Haiku by default; each has an environment kill switch (nexus-jypmj.2.8, RDR-232 Phase 2; client change, works on every engine; supersedes the "ships off" wording of the nexus-jypmj.2.3 and nexus-jypmj.2.2 entries above). With no setting, a plan whose declared answer kind cannot serve the request's kind is removed from every matcher branch and from the inline planner's examples, and a request the deterministic pass leaves unknown or facet-uncertain makes one Haiku call (about 0.002 USD, p50 about 4 s) to supply the kind. That call runs on about two thirds of requests, not on a few: the first pass left 97 of 143 replayed real runs and 20 of 29 held-out requests unknown. The cost is not yet counted in a run's cost_usd or in budget_usd (reported only, in request_coverage.extraction). Known limit, kept for this release: a synthesis-capable plan is admitted for a lookup, so the gate cannot remove such a plan from a lookup request, and a live single-theorem lookup ran a synthesis plan for 1.64 USD where the lookup plan costs about 0.13 to 0.16 (a tightened rule is measured after the release, nexus-jypmj.11). Mixed versions on one tenant: every session start runs nx upgrade --auto, which reconciles the builtin plans against that client's own templates, so a session on a client older than this release rewrites them without their declaration and the gate runs in shadow (library_not_reseeded) until the next session on a current client writes them back; upgrade every machine that shares the tenant.

    What the measurements show, and do not. The only measured fall in wrong-shape admission is in-sample: on the 24 golden requests it goes from 4 of 24 to 0, and the 4 includes G06, admitted only through a synthetic grown clone of plan 473 built from the incident (with the builtin plans alone it is 3 of 24 to 0). On the blind held-out set of 29 requests the gate removed no wrong-shape admission (1 of 29 before and after) and cost one non-lookup selection (H18; overall recall 0.724 to 0.690). With extracted kinds lookup recall holds (golden 1.0 to 1.0, held-out 0.4 to 0.4; with the first pass's kinds alone held-out lookup recall would fall 0.4 to 0.2). The acceptance thresholds were set after measuring, and three of them (T2, T3, T4) sit at the measured values. The extraction timed out or failed on 0 of 112 measured calls (56 on Haiku).

    NX_ANSWER_KIND_ELIGIBILITY=0 (also false, off, no) returns the gate to shadow mode, which computes and reports the verdicts in plan_eligibility and removes nothing, as before. NX_ANSWER_STRUCTURED_EXTRACT=0 (same values) makes no model call for request coverage, and it also puts the gate in shadow (shadow_reason extraction_disabled): the gate on first-pass kinds alone is the regime that failed the held-out lookup measurement, so the two switch together. A single extraction that times out, errors or is skipped for lack of time does not do this: that request keeps the first pass's kind and the gate keeps enforcing. A value of either variable outside the on and off vocabularies (disable, disabled) still reads as on, and logs one warning per process. NX_ANSWER_STRUCTURED_EXTRACT_MODEL defaults to haiku (a set value wins; NX_OPERATOR_MODEL_TIERING=0 does not remove it, since the model is always passed explicitly); the measurement is of claude-haiku-5-5, which the haiku alias named then, so a re-pointed alias, or an openai_compat install that ignores the alias, runs an unmeasured model. NX_ANSWER_STRUCTURED_EXTRACT_TIMEOUT of nan or inf now means the default (20 s), where nan used to pass the clamp.

    The gate is not enforced against a plan library that still holds a builtin template with no answer-kind declaration (one seeded before nexus-jypmj.2.1 and not reseeded): that request runs in shadow, with one warning per process naming the rows and nx plan reseed. Only a builtin row a reseed can fix is counted: a global-tier row tagged builtin-template whose dimensions match a shipped template (or a retired one, which a reseed deletes), and not tagged grown or ad-hoc. An orphan builtin-tagged row that no shipped template describes is left in place by the seed loader and cannot hold the library in shadow. A library with no such row enforces, and the envelope says it examined none (builtins_checked 0). An upgrade reseeds the builtins through the upgrade ladder's plan-library step, so an upgraded install enforces. The library state is read from the plan cache's last load (at most 90 seconds old), which costs no extra T2 call while the cache holds the rows; a request with no cache, or whose cache has not loaded, lists the active plans once and reuses the result (a failure to read it too) for 90 seconds, so a process without a cache makes one listing per 90 seconds, not one per request. New envelope keys in plan_eligibility: shadow_reason (disabled_by_setting, extraction_disabled, library_not_reseeded or null; when several apply the first in that order wins, and a request already in shadow for the first two does not read the library), library_check (checked, unreadable when the library could not be read and the gate kept enforcing, or null when no check ran), builtins_checked, undeclared_builtins and undeclared_builtin_names (at most 8). Settings are in docs/configuration.md.

  • A MinerU run refuses to start when pdf.mineru_page_timeout_s × pdf.mineru_page_batch is above 240 s, and the commands exit 1 naming both settings (nexus-tr1rj.2, RDR-237; client change, works on every engine). A streaming run's pipeline row is taken over after 300 s without a progress write, so the longest single MinerU attempt, which is the per-page timeout times the pages in one request, now keeps a 60 s margin under that, and each attempt (every server request, worker run, bisect step and restart wait) sends a heartbeat. This changes existing configs: the default 180 s × 1 page passes, but a config with pdf.mineru_page_batch: 2 or more at the default timeout (batch 2 is 360 s) used to run and now stops before the first page; lower pdf.mineru_page_timeout_s (a batch of 4 needs 60 s or less) or the batch. nx config set warns when a change would cross the limit. A setting that holds no integer (soon, true, 2.5) is named in the message, with no traceback. A MinerU server request that times out now sends its page to the next rung on the subprocess path instead of retrying the server (a batch that times out bisects on the subprocess path too), and a server whose requests keep timing out is replaced: after the first such timeout on a one-slot server (macOS), after two consecutive pages on a server with more slots; a server nexus did not start is marked down and never restarted. The server request timeout is the per-page budget times the pages in the range, where it was a fixed 300 s. Details: docs/configuration.md, docs/cli-reference.md § PDF Extraction Backends

  • Output labels that named the old scheme now name the new one (nexus-bivz3.12, .15). In nx catalog reconcile-stale --json the store_put-origin reason value chroma_uri is now note_uri: store_put_origin[].reason, the store_put_origin_by_reason count keys, and the same two inside zero_count_live; the recovery-bundle export's origin classifier uses the same note_uri label. The note reader's ReadOk.metadata["scheme"] and the https reader's served_from are "nx-note" (nx enrich info --json prints the stored URI's own scheme, so it follows the data). A script that matched on chroma_uri or chroma must match note_uri / nx-note.

Fixed

  • The TTL sweep's and the recovery export's chunk-row reads warn when the engine sends no count (nexus-6pfgb.33, client change; works on every engine). HttpVectorClient._note_rows (the sweep's guard) and chunk_rows (the export) raise when the engine reports more matched rows than it returned, but an engine before the store-get count field (nexus-hdx2u E3) sends none, so a dropped row was invisible and silent there. They now log the collection stub's once-per-process store_get_count_unreported warning, as col.get(ids=...) already did. Pinned by tests/test_6pfgb36_chunk_rows.py.
  • nx catalog export no longer stops at the first note written as several pieces; each such note exports its whole text (nexus-6pfgb.36, client change; works on every engine). The export kept a tripwire from when every store_put note was one chunk and raised SingleChunkInvariantError on any note with more than one manifest row. Since RDR-223 the note writer stores a long note as pieces under one catalog document, so on a tenant with any such note (347 of 433 notes on one live tenant) the recovery bundle could not be made, on 7.87.0 and earlier. The export now joins every piece in manifest position order into one knowledge_doc record (the bundle format version is unchanged), and import hands the whole text to the note writer, which splits it again. A keyless note takes its chunk-row keys from the first piece whose row is its own write (note_ttl.row_written_by), so a shared opening piece another note restamped no longer costs it its keys, and its tags and category alone from the first piece it solely owns (note_ttl.row_may_give_tags, nexus-6pfgb.35). Pieces are read in one paged request per collection through the new HttpVectorClient.chunk_rows, which raises on a store error where get_by_id returned nothing: a read failure stops nx catalog export with exit 1 and writes no bundle, rather than reporting the piece as gone; a note whose collection the engine no longer has registered still counts as a ghost. A note with some of its pieces gone from T3, or whose manifest positions do not run 0 to n-1 (a lost leading or middle row; a lost final row is not detected), is left out rather than exported short; the export prints WARNING: N note(s) written as several pieces have a piece missing from the store or a gap in their manifest with each note's collection and title (the first 20), and the bundle header carries notes_incomplete, and the command exits 1 (the bundle is still written, for inspection) so a scripted export-then-reinstall stops (nexus-6pfgb.37). A note with every piece gone still counts as a ghost and does not change the exit status. Pinned by tests/catalog/test_recovery_bundle.py, tests/test_catalog_export_import_cli.py and, against the real engine, tests/integration/test_6pfgb36_recovery_bundle_split_note.py.
  • A document that kills its aspect worker every time now reaches the terminal failed state instead of being reclaimed forever (nexus-j67ix.2, RDR-235 Gap 2 and Finding F3; engine change, no client half; ships with the next engine tag). AspectRepository.reclaimStale returned an aspect_extraction_queue row that sat in_progress past the 300 s window to pending without touching retry_count, so a document that crashes or hangs its worker (an out-of-memory, a kill) cycled claim, die, reclaim without end and never used the retry budget RDR-163 defined. A reclaim now counts as an attempt and applies the worker's own rule (_RETRY_MAX_ATTEMPTS = 5, now also AspectRepository.RETRY_MAX_ATTEMPTS, pinned equal by tests/test_aspect_retry_cap_parity.py): a row seen at retry_count 5 or more goes to failed with retry_count + 1 and last_error reclaimed_stale_retry_cap_exhausted, as markFailed would leave it; a row below the cap returns to pending with retry_count + 1 (new) and last_attempt_at cleared (as before). next_retry_at is still never written. The change is Java only, in one UPDATE: no changeset, no DDL, no table or view change. The periodic age-based reclaim sweep counts; the daemon's final reclaim_stale(0) sweep at a graceful stop, which a worker's self-fault stand-down also ends in, does not (nexus-j67ix.3, next entry). A batch that runs past the daemon's 300 s stale window (a batch of 5 has a 420 s budget) is reclaimed while still running and, if it then fails, pays a second attempt through markRetry (nexus-j67ix.4).
  • The aspect worker daemon's graceful stop no longer spends a retry attempt on the rows it releases (nexus-j67ix.3; engine and client halves, additive; the engine half ships with the next engine tag). POST /v1/aspects/queue/reclaim_stale takes an optional boolean count_attempt (default true, so an old client sees no change; a non-boolean value is a 400 naming the field). With false, every stale in_progress row returns to pending with retry_count and last_error unchanged and last_attempt_at cleared, and a row already at the cap goes to pending instead of failed. The daemon's final sweep at a graceful stop, which a self-fault stand-down also ends in, passes false, so a persistent host fault (a full disk) that respawns the daemon in a loop can no longer terminal-fail the same head-of-queue rows after about six cycles. Only the periodic age-based sweep counts. A new client against an engine without this change sends the key, which that engine ignores: every released engine through 0.1.165 never counted a reclaim, and a develop build that carries only nexus-j67ix.2 counts until it is upgraded. Pinned by AspectRepositoryTest (the uncounted rows below and at the cap, the two-argument overload still counting), AspectHandlerJsonValidationTest (the 400), AspectSupersededWriteTest (the route against the database: false spends nothing, true and an absent key spend one), tests/db/test_http_aspects_stores.py (the wire body), tests/daemon/test_aspect_worker_reclaim.py and tests/test_yg70j_self_fault_touches_no_row_state.py (the periodic sweep counts, the stop and stand-down sweeps do not).
  • A tenant or embedding model created after boot no longer gets nexus_diag SELECT on its chunk leaves (nexus-h4w9o; engine change, no client half; ships with the next engine tag). nexus.partition_copy_access, the one function that copies a parent table's ACL onto a new leaf or model partition (engine-service-v0.1.150 onward), copied nexus_diag's SELECT on nexus.chunks (granted every boot for the diag_chash_conformance view), and the boot step that strips it from leaves ran only at the next boot. Liquibase vectors-039-1 redefines the function to skip any grantee whose role has rolbypassrls, which covers nexus_diag and any BYPASSRLS role added later. That is the semantic, not a role name: a grant any BYPASSRLS role holds on a parent (a DBA's backup or reporting role included) is no longer mirrored onto new leaves and model partitions, and partition_sync_access (the boot repair of partition access drift) now removes such grants from existing ones. The parent keeps its grants, and every other grantee (nexus_svc, PUBLIC) is copied as before; this assumes nexus_svc is NOBYPASSRLS, as provisioning makes it. No DATA EFFECT: a function definition only. No repair step: leaves that already carry the grant are stripped at the deploy boot. nexus_diag could already read every tenant's chunks through the parent, so this closes a second path and the boot-timing dependence, not a new exposure. Pinned by PartitionCopyAccessDiagExclusionIntegrationTest (tenant leaves, a new model partition and partition_sync_access, each red without the changeset).
  • rdr-frontmatter-v1 aspect rows now carry the document's frontmatter (nexus-xho6c). The extractor reassembled an RDR from its T3 chunks, and the indexers strip the frontmatter before chunking, so every rdr__ aspect row had empty extras (377 of 377 on the live tenant, RDR-232's included): no rdr_id, rdr_status or rdr_type. When it sources the text from chunks, the extractor now reads the header (the leading --- block, nothing else) from the source .md file, found through the catalog's source_uri for the document or an absolute lookup_path or source_path (resolved first; only a .md under a docs/rdr directory is read), and puts it back in front of the chunk text; the body stays what was indexed. Without a readable file the row is as before. Applies to the aspect worker (single and batch) and nx enrich aspects. Existing rows are not rewritten by this change: nx enrich aspects <rdr collection> --all --validate-sample 0 re-extracts them (the parse is deterministic; without --validate-sample 0 the command also spot-checks 5% of the new rows with a model call; run it on a host that has the files).
  • The streaming PDF post-pass updates the chunks of the PDF's own manifest, not the rows that carry its content_hash; a row another document restamped is written without deleting or blanking anything (nexus-ahm18, client change; works on every engine including 0.1.164). A chunk row is one per chunk text and its content_hash is the last writer's, so selecting where content_hash=H missed this PDF's chunks another document had restamped (they kept the other document's title and author) and hit rows the PDF does not own. pipeline_stages._enrich_metadata_from_extraction (title, author, extraction_method, quality_gate_overridden) and _update_chunk_metadata (the table_page tag) now read the document's manifest (doc_id to get_manifest, one call per post-pass) and update exactly those chunks, a shared one included and no other; chunk rows are read by id in batches of 300. This writes rows the old selection never wrote. When a PDF's post-pass runs after another document restamped a shared chunk (a retry or a resume of a failed post-pass, or a race with that document), the old selection skipped that row and the new one writes it: the row's title, source_author and extraction_method are replaced by this PDF's (the shared row stays last-writer-wins until those keys move to the catalog, nexus-6pfgb.6, and the chunk-row keys are retired, nexus-6pfgb.27). Nothing else is touched on such a row: no key is deleted (the other document's quality_gate_overridden audit flag stays, and this PDF's own flag is not set on it), a key whose value here is empty is skipped (so the other document's author or method is never blanked), and table_page is set only where this document's own manifest label says the position is a table page, never from the page the other document wrote on the row. A row that still carries this PDF's hash is written as before (empty strings included, the stale flag removed on a healthy run). table_page is judged by the document's own page where the engine records labels (labels_written); engine 0.1.164 records none, so there it is the chunk row's page on a row this PDF last wrote, and a restamped row is not tagged at all. A catalog read failure fails the post-pass, which keeps the pipeline buffer for a retry; so does a real run whose selection is empty although chunks were uploaded (post_pass_selected_no_chunks warning naming the document). A dry run (dry_run, which has no catalog and a throwaway store) keeps the content_hash selection. Tests: tests/test_ahm18_pdf_postpass_by_manifest.py, tests/integration/test_rdr223_pdf_journey.py::test_a_rerun_post_pass_updates_every_chunk_of_its_manifest_including_one_another_pdf_restamped.
  • nx enrich bib selects by the catalog document's manifest, and --backfill-catalog is fill-only and trusts only a chunk one live document holds and that names it as writer (nexus-6pfgb.21, nexus-7mcev, x54wv census rows 10, 11 and 14; client change, works on every engine). A T3 chunk row is shared by every document that holds the text, and its title and bib_* are the last writer's. nx enrich bib grouped rows by that title and skipped rows that already held an id, so a document whose chunk another writer had restamped was enriched with the other's bib, counted as already enriched, or never looked up (a document whose every chunk was shared was never enriched); --backfill-catalog copied one representative chunk's bib into the catalog row matched by title, so a document could take another's values. Now the unit is the catalog document: its manifest names its chunks, its own catalog row says whether it is enriched, the lookup title is its catalog title, the catalog row is written by tumbler, and the chunk rows get the bib only where that document is their sole holder (a chunk two documents hold keeps the values it had, which may be another document's). --backfill-catalog is fill-only (a document whose catalog row already holds the backend's id is left as it is) and takes the bib from a chunk only when exactly one live document holds it AND the chunk row names that document as its last indexer (a note's catalog_doc_id, else the file content_hash the indexers stamp, against the document's own: the indexers' chunk rows carry no catalog_doc_id). That is who last indexed the row, not who wrote its bib_*: a re-index restamps content_hash and keeps the bib_*, so a bib a former co-holder wrote and a later re-index kept cannot be told apart and may be copied onto the indexing document's empty catalog row. The verb is a recovery tool; prefer nx enrich bib (the API lookup) when in doubt. It is fill-only per backend id: a row holding the other backend's id is still filled and its author, year and venue overwritten. It reads every such chunk of a document, skips and names a document whose chunks disagree on the id, writes the row by tumbler, and reports what it skipped instead of matching by title. Only documents that do not hold the id yet have their chunk owners looked up, in batch through the chash-positions route with a manifest fallback filtered to the collection, so this holds on engine 0.1.164. The verbs now say "documents" where they said "titles", and stop with no change when the catalog cannot answer, where they used to fall back to chunk titles. nx catalog backfill registers a paper collection from its first chunk's row only when no live document holds that chunk; a chunk one document holds supplies that document's catalog title, author and year, and a first chunk several hold skips the collection with a warning (a collection the papers curator already holds is not read for holders, and so is not warned about on every re-run). The dead source_path chunk-key reads in nx enrich bib are gone (the document's file name comes from its catalog row); the legacy-recovery readers in nx catalog backfill keep theirs, since they recover from chunks that predate RDR-102. The nx doctor PDF-stub row no longer says nx enrich bib skips title-less chunks. A document enriched before nexus-9l2lg has its id only on chunk rows; where a chunk it alone holds names it and carries the id, nx enrich bib leaves it to --backfill-catalog (no API call), and any other such document costs one backend lookup again, so run --backfill-catalog first on an old collection (nx dt index --enrich inherits the cost). A document whose attributed chunks disagree on the id (which --backfill-catalog skips) is looked up by nx enrich bib instead of being left to neither verb. A document with no catalog title, and a titled one with no chunk in the collection, are counted in the progress line; --limit bounds the backend calls only, not the manifest read or the chunk-owner lookup. nx dt index --enrich warns, prints its indexing summary and carries on when the catalog cannot answer, instead of failing after the indexing. generate_citation_links now reads the bib ids from the catalog columns (with meta as the fallback for old rows), so the citation links nx enrich bib says it generates are built (nexus-7mcev). One-time effect after upgrading: the link scan covers the whole catalog, so the first run that enriches a document (or nx dt index --enrich, or nx catalog generate-links) creates cites links among every paper enriched since nexus-9l2lg, and prints that catalog-wide count, not just the new paper's; nx catalog generate-links --dry-run reports the volume first.
  • nx store expire judges each owning note by its own TTL and its own clock, and retires every note that has lapsed (nexus-6pfgb.15, census row 8; client change, works on every engine). Two notes with identical text share one chunk row, and the row's ttl_days and indexed_at are the last writer's. If the permanent note wrote last, the TTL note never expired (the sweep found no candidate); if the TTL note wrote last, the sweep saw two owners, could not name one and tombstoned neither. Now the sweep reads each note's own catalog entry (meta.ttl_days, kept there since nexus-6pfgb.5), lists the live notes of each knowledge collection (one catalog listing per collection), and retires every one whose TTL has run out, through the note delete path of nx store delete --title (manifest retracted, then the document tombstoned, all or nothing per note). Its chunks then go to the engine, whose delete anti-join keeps any chunk another live note holds: a lapsed note whose chunk a permanent note shares is tombstoned and the chunk stays served. ttl_days of 0 (the retraction value a permanent re-put writes) and an absent one are permanent; the catalog's 0 is never handed to is_expired. The clock is a new key, meta.put_at, the time of the note's last put, written with ttl_days on every put (catalog_store_hook_tracked, note_catalog_meta). The entry's indexed_at is not the clock: the engine refreshes it on every manifest write, a heal or a backfill included, so a TTL counted from it would slide later with each one. An entry that carries ttl_days but no put_at counts from indexed_at, which is never earlier than the put, so it expires late and never early. A note whose entry has neither key (put before the catalog held TTLs) is judged by the chunk row's TTL as before, but only when it is the chunk's sole live owner and the row is its own write (the row's catalog_doc_id is that note's tumbler and its title is that note's, note_ttl.row_written_by; an .nxexp import keeps the source box's stamp on each row while the destination mints its own tumblers, so the tumbler alone could match by chance): the row is the last writer's and is not rewritten when a co-owner leaves, so a permanent note left behind by a sweep that retired its TTL co-owner is not retired by the next sweep on that co-owner's TTL. A chunk several such notes hold, a lapsed row some other note wrote (or nobody stamped) over one such note, and a note that has its own keys are not judged by the row; only the chunks whose owners the catalog cannot judge are counted, and the CLI reports that count: a chunk with several owners of which at least one has no keys of its own (several keyless notes, or a keyless note beside a keyed one), a chunk with one keyless note that did not write the row, and a chunk whose sole owner is a file-indexed document or another non-note entry. A lapsed note is read again just before it is retired and skipped if a put has since made it permanent or restarted its clock; a ttl_days too large to count, or not a number, is permanent and no longer aborts the sweep. A chunk with no live owner goes as before. A note that cannot be retired stays live and whole, its chunks are held back, and nx store expire names it and exits non-zero (HttpVectorClient.expire_report() returns the counts, expire() raises ExpireIncompleteError). nx store expire also prints how many notes it retired and how many chunks it deleted; the notes can outnumber the chunks. The expires in Nd line on hydrated text and search hits counts from the same put_at. The in-memory T3Database.expire (tests only; production is HttpVectorClient) has no catalog and is unchanged. A re-put, with unchanged text too, starts the TTL over. Tests: tests/integration/test_6pfgb15_expire_per_owner.py (the bead's two pins against the real engine, a retracted 0 surviving, each owner on its own clock, the legacy rules, a legacy permanent note surviving two sweeps), tests/test_6pfgb15_note_ttl.py, tests/test_6pfgb5_note_meta_homes.py, tests/test_context_annotations.py.
  • nx store expire no longer retires a note whose owner made it permanent through a client or writer that does not record TTLs in the catalog, and a garbage ttl_days on a chunk row no longer aborts the sweep (nexus-6pfgb.30; client change). A note's own ttl_days and put_at live in its catalog entry and are written only by the put path of a client that has nexus-6pfgb.5. A re-put by an older client, or by any writer that bypasses that path, rewrites the chunk row and leaves the catalog entry as it was, so the sweep would retire a note its owner had just made permanent. The .nxexp import is such a writer: it writes chunk rows and leaves the catalog entry's note keys alone, and it still does, so an imported note makes no TTL claim of its own and the catalog never claims one the import did not make. Clients on one tenant should move to this release together. Before it retires a lapsed note the sweep now reads the note's own chunk rows (by its manifest chashes, 300 ids a request; a row counts when it is stamped with the note's tumbler, whatever title it carries, or carries the note's title, note_ttl.row_names_note; an older client's untitled put, or put under another title, of the note's text stamps the row with the note's own tumbler, and an untitled note is covered the same way; the check can only refuse to retire, so it over-matches on purpose), and leaves the note alone when one of them records a put the catalog does not: the row's TTL is not the catalog's, or the row's indexed_at is more than five minutes after the catalog's clock. nx store expire reports such a note on stderr (catalog_ttl_stale on ExpireReport) with the remedy: re-put it with this client, with or without --ttl, or delete it with nx store delete --title; the note and its chunks stay. The held count is on the stdout summary line (Expired 0 entries. 1 lapsed note held, not retired.); the stderr cause reads "may be an older client or an import", because a put that took more than five minutes between its catalog stamp and its chunk write gives the same result. A held note never expires until it is re-put or deleted, so a scheduled sweep names it on every run and the retention the TTL promised is not kept for it. If the rows cannot be read, or a store-get reports more matched rows than it returned, the note is not retired and the run exits non-zero like any note that could not be retired. A note whose row another note wrote last is not covered: its own row is gone, so an older client's permanent re-put that a same-text co-owner then overwrote cannot be seen. metadata_schema.is_expired now reads a chunk row's ttl_days that is not a plain positive number (a string, a list, a bool, NaN) or is past timedelta's range (10**10, inf, 1e300), and a naive indexed_at beside an aware one, as not lapsed; before, such a row raised OverflowError or TypeError in the row scan and aborted the sweep before the catalog pass. Tests: tests/test_6pfgb15_note_ttl.py, tests/integration/test_6pfgb15_expire_per_owner.py.
  • A put never adopts, re-sources or re-keys another note, so nx store expire can no longer retire a permanent note through a later put of its text (nexus-6pfgb.31; client change, works on every engine). The MCP store_put title text now says what an empty title does. The put path's chash dedup reconciled onto any note holding the text unless the put had a title and the note a different non-empty source URI. So an untitled store_put of a titled note's text (with a ttl) adopted the titled note, blanked its source URI and stamped ttl_days on it, and a put under a second title adopted a legacy note whose source URI was empty, gave it the put's source URI (and the chunk row the put's title) and the TTL; nx store expire then retired the note and deleted its chunk. A put now reconciles only onto the note with its own title (an untitled put only onto an untitled one with no source URI, which is what every untitled put writes; an untitled document registered with a source URI, which nothing in the tree writes but catalog register can, is not reused, and an untitled put of its text makes a document of its own, so a later chash-keyed nx store delete of that text finds two owners, calls it ambiguous and leaves the catalog row for nx catalog gc, as it already does for two titled notes sharing text; nexus-6pfgb.33). Identical text under another title, or under none, is a second document sharing the chunk, as nexus-enej7 already made it for two titles; a repeated untitled put reuses its one untitled document (the lowest tumbler when several untitled documents share the text, so they do not multiply), and an untitled put never writes a document's source URI. Two visible consequences: an untitled put of a titled note's text now creates a second, untitled catalog document that shares the chunk, so a listing shows two documents for one text; and a re-put under a new title makes a second document instead of renaming the note. A legacy note with an empty source URI is still healed by a re-put under its own title. A put never writes another note's source URI, title or note keys in the catalog; the shared chunk row still carries the last writer's title, tags, category and TTL (RDR-108).