Repository navigation
v7.88.0
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-editships 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 setuponce per machine and repository: it checks that Nexus and T2 answer, prints the two temp-folder permission rules for~/.claude/settings.jsonwhen 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-pagegets no site-page layer instead of an error. The skill and the line-editor agent move from.claude/intoprose-edit/, so a nexus checkout needs the plugin installed to run/prose-edit.nx_answercan check whether the evidence covers what was asked before it answers (opt in withNX_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: oneoperator_checkcall 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: withNX_ANSWER_COVERAGEunset (or0,false,off,no) no boundary is handed to the runner, every run isunassessedwith the reasonswitch_off, andnx_answeris exactly what it was before Phase 3.NX_ANSWER_COVERAGE=1(alsotrue,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 areunassessed, the answer is generated), never "nothing supports this facet"; a facet with both supporting and contradicting evidence is covered and markedconflicted, and the repair queries stay inside the caller's scope and the plan step's own filters. What a run returns now.complete: anansweredresult, as before.partialwith a usable answer (answered prose with at least one covered facet, or with some facet unassessed): the newanswer_shapeinsufficient_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 shapeuncoveredis 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_evidencekeeps 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 coveragenonein its run summary, notunassessed. A generation failure keeps the existing error shape. The single-query fast path has no final reduction and reports coveragenone. Each/recordand/completewrite carries a version-1run_summary(coverage counts and state, request kind, eligibility, per-stream counts, scope, repair, cost and latency; no question text, and withtrace=Falseno facet text or source hints either), sent only when the engine's/versionadvertisesnx_answer_run_summary_supported(engine-service-v0.1.167 and later). The structured envelope gains arun_summarykey:persistenceispersistedwhen a readback matches a digest over canonical JSON,droppedwhen it does not,unsupportedon an engine without the flag (today's deployed engine, and the public edge until it allowlists the flag),unverifiedon a text call, which reads nothing back, andnot_recordedwhen 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-levelcoverageobject at the state the call returned (requested, covered and residual facet ids, primary-source state, corpus counts and the repair attempt;unassessedwith a reason when no boundary ran; ids, states and counts only, source wording blanked withtrace=False), the same object a continuation envelope carries ascontinuation.coverage. The summary's coverage counts andcoverage_stateare the state the call returned, so a partial ledger whose prose turned out to be no usable answer is recordedunavailable.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 withcoverage_partial1), fortrace=Trueandtrace=Falserows alike; a row without a summary is bucketed from the notice when its text is visible, and atrace=Falserow without a summary stays redacted. Uncovered and no-evidence rows count as degenerate. The two newanswer_shapevalues are wire-visible, and thenx_answertool description, theanswer_shapeandrun_summaryentries ofdocs/cli-reference.mdand thenx answer-runshelp now say so (nexus-jypmj.3.6).- The engine stores a versioned run summary on each
nx_answerrun: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/recordand/completeaccept an optionalrun_summaryJSON object and write it with the run row, so on/completeit 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 namingrun_summary, never a truncation. It reads no key of the object, so the client owns itsversionand its shape. An absent ornullfield stores SQL NULL, with no backfill and no default./importand/import_batchcarry it (a text-serialised object is accepted), andGET /v1/telemetry/nx_answer_runs/queryreturns it on every row as an object,nullwhen none was recorded (PostgreSQL normalises key order, so compare canonical JSON).GET /versiongainsnx_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. Changesettelemetry-016-1is oneADD COLUMN(migration_ddl_per_worker2140 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). MCPstore_put,nx store put,nx memory promote, the recovery-bundle import and the exporter'simport_collectionask the serving engine for its version (nexus.db.serving_engine_version) and raiseNoteUriEngineTooOldError, withnx daemon restart-staleas 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'simport_collectionchecks only for a target that can mint a note URI (notdocs__,code__orrdr__), 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 tomain) 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 onsource_uri).scripts/build-gate-jar.sh,tests/e2e/local-service-gate.shand the two gate-jar stamp steps inci.ymlstampmax(REQUIRED_ENGINE_VERSION, NOTE_URI_MIN_ENGINE_VERSION), so the gate jar is never under the floor. nx_answerjudges 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). Newnexus.plans.eligibilityimplements 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 thebuiltin-templatetag and acurateddeclaration; agrowntag or declaration makes it grown; a row that declares nothing is unattested and counts as an untrusted unknown plan, so it serves onlylookupanddiscovery/listingrequests, and anunknownrequest reaches curated synthesis and curated unknown plans only.plan_matchtakes an optionaleligibilityfilter 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_missis handed theRequestCoverage. The module constantANSWER_KIND_ELIGIBILITY_ENABLEDisFalse: the filter then only counts,plan_matchreturns 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-presentplan_eligibilitykey (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 decidedexplicit,heuristicormodel-assisted, andunknownotherwise. Eligibility honours that recorded kind for no route yet (eligibility.TRUSTED_ROUTESships empty): every stamp, the caller'sexplicithint 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 (reasonunreviewed_route); the route is still recorded at the grow site. A fault while judging a candidate never failsplan_match: it is counted underjudge_error, kept when the filter is not enforcing and dropped when it is. Before the switch is turned on, runnx plan reseed: a builtin seeded before nexus-jypmj.2.1 has no declaration until then and reads asundeclared_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 derivesdebug.- Plans declare the kind of answer they serve: a versioned
answer_kindfield inplan_json, set on the 12 builtin templates (nexus-jypmj.2.1, RDR-232 Phase 2; client change, works on every engine). The vocabulary islookup,synthesis,discovery/listing,comparison,audit,debugandunknown(nexus.plans.answer_kind.PlanAnswerKind, distinct from the terminal-stringAnswerShape). A template writesanswer_kind: <kind>and the seed loader stores{"version": 1, "kind": ..., "provenance": "curated"}insideplan_json, never indimensions, so plan identity is unchanged. A missing, malformed, wrongly versioned or unsupported declaration decodes tounknown, 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; onlyplan_jsongains the field. A plannx_answergrows from the inline planner is saved with grown provenance and records the request's kind only with recorded route provenance (explicit,heuristicormodel-assisted), andunknownotherwise; nexus-jypmj.2.3 passes the request's kind and route at the grow site. Only the seed loader declarescuratedand only thenx_answergrow site declaresgrown:plan_saveremoves anyanswer_kindkey from the plan it is given (its reply says so) and a template's ownplan_jsonmay not carry one, so a saved plan declares nothing and decodesunknown. Read paths never retire a row for its declaration:nx plan hygieneleaves a future-version, malformed or unrelatedanswer_kindalone.nx plan reseedrewrites 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) andMatch.answer_kindcarry the declaration for the eligibility step (nexus-jypmj.2.3), andGOLDEN_LABEL_TO_KINDmaps the golden-set labels (discovery) onto the plan vocabulary (discovery/listing). nx_answerderives aRequestCoveragebefore 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 newnexus.plans.request_coveragereads the question without a model call: the answer kind (thePlanAnswerKindvalueslookup,synthesis,discovery/listing,comparison,audit,debug, orunknownwhen the phrasing does not settle one;verb_inferis not consulted; a bare question word islookuponly for a single-facet question not joined to a second one byand what,and howand 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 (thesourcebinding 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_certainisfalseand onewhole_questionfacet carries the full, uncut question. The result is the new always-presentrequest_coveragekey of the structured envelope, a counts-onlyrequest_coveragesummary inevidence_diagnostics, and onenx_answer_request_coveragelog event (facet text and source hints only withtrace=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 ofrdr-lifecycle.tomlplus 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), anddimensions={"answer_kind": ...}is taken as the caller's kind hint and removed beforeplan_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 byNX_ANSWER_STRUCTURED_EXTRACT_TIMEOUT(default 20 s, at most 60) and by what is left of the answer'sbudget_seconds(under one second left, no call:outcomeskipped_deadline), and reported inrequest_coverage.extractionwith its own latency and cost. Dispatch is handed a deadline so its own kill path ends theclaude -pchild, 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,projector subtreescopeis qualified by the session's project when that project is reliable, and an RDR id that only the question names is now resolved like asourcebinding (nexus-jypmj.2.4, RDR-232 Phase 2, assumption A5; client change, works on every engine). Resolution order: an explicitowner/projectbinding, then a catalog-subtreescope, then the active project (nexus.plans.active_project).scope="rdr"is a corpus and never selects a project. The active project is reliable only whenCLAUDE_PROJECT_DIRis 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), andrdr_canonical.current_rdr_ownerreturns 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-namescope, or a catalog failure leaves the source ambiguous: the candidates of every project are named, andevidence_request.active_projectgives the reason (no path appears in it). Where it never applies: Claude Desktop and Cowork start the server in the plugin directory with noCLAUDE_PROJECT_DIR, so on those hosts a number that more than one project holds is always the candidates ambiguity; passownerorproject(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, markedinferred, 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.reasonsays "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 asinferredorapplied. A source the inference chose among projects that all hold its number keeps asource_inferredline in the request ledger beside theinferredmark. When it applies,evidence_request.owner.statusis the newinferred(contributionactive_project), the source reportsinferred: 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'ssource_unqualifiedline stays in the request ledger. A document tumbler assourceis 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, nosource) 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 explicitsourcebinding wins and replaces the ids read from the text (a malformedsourcestill counts as a binding). A text-derived source is never an owner:evidence_request.source_originandnamed_sources.origin(and each source'sorigin) sayquestion, and only anowner/projectbinding 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 innamed_sources.droppedand in a text notice, never silently dropped (asourcebinding keeps its cap of 6). A range (RDR-078 through RDR-095, alsoto,until, a dash; the second end may be a bare number afterthrough,thru,until,tillor an en or em dash, as inRDR-078 through 095) is not expanded: its two ends are the sources, andnamed_sources.rangesand 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: aftercompare,contrast,differenceorversus,RDR-078 to RDR-095names 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'sbudget_secondsif that is shorter), and its envelope gainsevidence_request,named_sourcesand, once resolved,primary_evidence.request_coverage.active_project_tieris nowwired. - 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_ROUTESis empty: a caller'sdimensions["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 failnx_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 beyondunknown; 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 formSynthesize 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 ascan_limitambiguity), 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_omittedandrequest_coverage.named_source_countgive the rest; the dropped-sources line saysand 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 byCLAUDE_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) Withtrace=Falsethe 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 invaliddimensions["answer_kind"]is still ignored and is now recorded as akind_hint_invalidambiguity; thedimensionsparameter text saysdiscovery/listing.docs/cli-reference.mdlistssource_rangesamong therequest_coveragekeys. - A bare
source=RDR-NNNwith sibling files resolves to the file whose frontmatteridis that number (nexus-xho6c, RDR-232 Phase 1; Sam's ruling of 2026-10-25; client change, works on every engine). Seven RDR numbers indocs/rdrhave sibling files (rdr-200has nine: the RDR and its phase notes), andrdr_canonicaltreats each as its own document, so a bareRDR-200wascandidatesand 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 therdr-frontmatter-v1aspect row (extras.rdr_id) and, where the row does not show the frontmatter was parsed (no row, another extractor, null fields, or emptyextras), the file under the registration'sfile://source, when this host has it: a.mdunder adocs/rdrdirectory (and the owner's owndocs/rdrtree 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 asourcebinding is (RDR-200,rdr-200,200are equal;companion-noteis not an RDR id;id: 040is 40). Exactly one carrier resolves, anddetailplus 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 staycandidatesbecause an unread sibling might be a second carrier (RDR-232 keeps this as a decision for Sam):detailcounts the files by cause (no aspect row yet, a row with no parsed frontmatter, file not readable on this host, anidvalue that is meant as an RDR id but cannot be read, such asRDR-200 (superseded)) and, where aspect rows are missing or unparsed, names the remedy:nx enrich aspects <rdr collection>for missing rows, with--allfor rows that exist but have emptyextras. 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 capsmax_frontmatter_lookups(24) andfrontmatter_call_seconds(5; each read is also clamped to what is left of the answer's budget) onPrimaryEvidenceCaps. Seedocs/cli-reference.md. - The recovery bundle exports each note's own
tags,categoryandttl_days, andnx catalog importrestores 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 carryttl_daysat all.nx catalog exportnow reads the three keys from each note's catalog entry (nexus-6pfgb.5), and eachknowledge_docrecord has attl_daysfield (a positive integer ornull; 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, mapping0, a negative or a non-integerttl_daysto permanent (the catalog stores0as its retraction sentinel andput_noteraises 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 stampsput_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 rulenote_ttl.row_written_by(the row'scatalog_doc_id, which every note write stamps and a later write by another note replaces, is the note's tumbler and the row'stitleis the note's; a row with no stamp is nobody's forttl_dayseven when the note is the only owner left, because a departed co-owner's values stay on the row, nexus-6pfgb.34, but givestagsandcategoryto 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 itsttl_daysas 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 printsnote keys not recovered: Nfor those where the row held a value. Against 7.87.0, which copied the chunk row's tags and category into every record (and nottl_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.nxexpimport, whose row carries no writer stamp, another box's stamp, or another title, now exports with itsttl_dayspermanent (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 expiresttl_daysafter the import (the note writer stampsindexed_atthen), 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 bytests/catalog/test_recovery_bundle.pyand, against the real engine,tests/integration/test_6pfgb14_recovery_bundle_note_keys.py. - Client half of RDR-231: a note's
source_uriisnx-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 andnx catalogmint and readnx-note://; achroma://note URI is still accepted on input (an old export, a recovery bundle, a link) and is rewritten tonx-note://where the client reads it and by the engine on register and backfill. File-routedchroma://rows (rdr__,docs__,code__) are residue and are not rewritten to a note.nx enrich list --scheme nx-notelists a note's aspect rows (--scheme chromanow prints a hint to usenx-note; it matches only residue).nx doctor --check-referencesreads achroma://residue row asunknown, never a failure. Against an engine older than the floor, analytic reads match no notes: the aspect operators (aspect_sqland its title lookups) look a note up by itsnx-note://URI, an engine that has not run the backfill still holdschroma://rows, and the operator reports "does not match" for every note until the engine is upgraded and the backfill has run. --extractor pymupdf(orpdf.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 recordedunextracted; 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 recordsextraction_method: pymupdf;autonever picks it.nx index pdf(a file and--dir),nx dt indexandnx dt capturetake 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 printsExtractor: pymupdf, text layer onlyand the pages with no text layer, and it keys on the method the catalog recorded: apymupdfrerun over a kept buffer a MinerU run left is recordedmineru+degradedand 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 fornx index pdf, the setting fornx index repoandnx collection reindex, the flag fornx dt, none fornx mineru start), including the Windows runtime gap and the install and network failures. This changes existing behaviour:nx index repoandnx collection reindexnow readpdf.extractor; before, they always ranauto. A repo configuredminerusends every new or changed PDF to MinerU alone, and one configureddoclinggets formula-stripped extraction; PDFs already indexed and unchanged are not re-extracted.nx dt indexandnx dt capturestill read the flag only. Details:docs/cli-reference.md§ PDF Extraction Backends,docs/configuration.mdnx store getand the MCPstore_getlist 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 carriesextraction_methodandextraction_pages(rung to 1-based pages,{}when every page was clean, so a clean re-extract clears an old map).nx store getandstore_getprint aDegraded: mineru-no-formula 3,7; pymupdf-text 12line afterExtractor:for a chunk of such a document; a chunk held by several documents gets one line each, labelled by title.nx store get --jsongains adegraded_pageslist (tumbler,title,extraction_pages) only when some holding document has degraded pages, so scripts need not scrape the text.nx index pdfandnx dt indexprintDegraded 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 reporecords the same map on the document it registered. A run that cannot record it (no catalog, a refused ephemeral path, a failed write) logscatalog_pdf_extraction_not_recordednaming 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 logsresume_provenance_unknownand keeps the method atmineru+degradedinstead of claiming a cleanmineru. Details:docs/cli-reference.md
Changed
-
nx_answernow 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 leavesunknownor 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 requestsunknown. The cost is not yet counted in a run'scost_usdor inbudget_usd(reported only, inrequest_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 runsnx 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(alsofalse,off,no) returns the gate to shadow mode, which computes and reports the verdicts inplan_eligibilityand 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_reasonextraction_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_MODELdefaults tohaiku(a set value wins;NX_OPERATOR_MODEL_TIERING=0does not remove it, since the model is always passed explicitly); the measurement is ofclaude-haiku-5-5, which thehaikualias named then, so a re-pointed alias, or anopenai_compatinstall that ignores the alias, runs an unmeasured model.NX_ANSWER_STRUCTURED_EXTRACT_TIMEOUTofnanorinfnow means the default (20 s), wherenanused 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 taggedbuiltin-templatewhose dimensions match a shipped template (or a retired one, which a reseed deletes), and not taggedgrownorad-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_checked0). 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 inplan_eligibility:shadow_reason(disabled_by_setting,extraction_disabled,library_not_reseededornull; 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,unreadablewhen the library could not be read and the gate kept enforcing, ornullwhen no check ran),builtins_checked,undeclared_builtinsandundeclared_builtin_names(at most 8). Settings are indocs/configuration.md. -
A MinerU run refuses to start when
pdf.mineru_page_timeout_s×pdf.mineru_page_batchis 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 withpdf.mineru_page_batch: 2or more at the default timeout (batch 2 is 360 s) used to run and now stops before the first page; lowerpdf.mineru_page_timeout_s(a batch of 4 needs 60 s or less) or the batch.nx config setwarns 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 --jsonthe store_put-originreasonvaluechroma_uriis nownote_uri:store_put_origin[].reason, thestore_put_origin_by_reasoncount keys, and the same two insidezero_count_live; the recovery-bundle export's origin classifier uses the samenote_urilabel. The note reader'sReadOk.metadata["scheme"]and the https reader'sserved_fromare"nx-note"(nx enrich info --jsonprints the stored URI's own scheme, so it follows the data). A script that matched onchroma_uriorchromamust matchnote_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) andchunk_rows(the export) raise when the engine reports more matched rows than it returned, but an engine before the store-getcountfield (nexus-hdx2u E3) sends none, so a dropped row was invisible and silent there. They now log the collection stub's once-per-processstore_get_count_unreportedwarning, ascol.get(ids=...)already did. Pinned bytests/test_6pfgb36_chunk_rows.py. nx catalog exportno 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 everystore_putnote was one chunk and raisedSingleChunkInvariantErroron 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 oneknowledge_docrecord (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 newHttpVectorClient.chunk_rows, which raises on a store error whereget_by_idreturned nothing: a read failure stopsnx catalog exportwith 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 printsWARNING: N note(s) written as several pieces have a piece missing from the store or a gap in their manifestwith each note's collection and title (the first 20), and the bundle header carriesnotes_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 bytests/catalog/test_recovery_bundle.py,tests/test_catalog_export_import_cli.pyand, 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
failedstate 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.reclaimStalereturned anaspect_extraction_queuerow that satin_progresspast the 300 s window topendingwithout touchingretry_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 alsoAspectRepository.RETRY_MAX_ATTEMPTS, pinned equal bytests/test_aspect_retry_cap_parity.py): a row seen atretry_count5 or more goes tofailedwithretry_count+ 1 andlast_errorreclaimed_stale_retry_cap_exhausted, asmarkFailedwould leave it; a row below the cap returns topendingwithretry_count+ 1 (new) andlast_attempt_atcleared (as before).next_retry_atis 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 finalreclaim_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 throughmarkRetry(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_staletakes an optional booleancount_attempt(defaulttrue, so an old client sees no change; a non-boolean value is a 400 naming the field). Withfalse, every stalein_progressrow returns topendingwithretry_countandlast_errorunchanged andlast_attempt_atcleared, and a row already at the cap goes topendinginstead offailed. The daemon's final sweep at a graceful stop, which a self-fault stand-down also ends in, passesfalse, 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 byAspectRepositoryTest(the uncounted rows below and at the cap, the two-argument overload still counting),AspectHandlerJsonValidationTest(the 400),AspectSupersededWriteTest(the route against the database:falsespends nothing,trueand an absent key spend one),tests/db/test_http_aspects_stores.py(the wire body),tests/daemon/test_aspect_worker_reclaim.pyandtests/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_diagSELECT 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), copiednexus_diag's SELECT onnexus.chunks(granted every boot for thediag_chash_conformanceview), and the boot step that strips it from leaves ran only at the next boot. Liquibasevectors-039-1redefines the function to skip any grantee whose role hasrolbypassrls, which coversnexus_diagand 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, andpartition_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 assumesnexus_svcis 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_diagcould 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 byPartitionCopyAccessDiagExclusionIntegrationTest(tenant leaves, a new model partition andpartition_sync_access, each red without the changeset). rdr-frontmatter-v1aspect 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 everyrdr__aspect row had emptyextras(377 of 377 on the live tenant, RDR-232's included): nordr_id,rdr_statusorrdr_type. When it sources the text from chunks, the extractor now reads the header (the leading---block, nothing else) from the source.mdfile, found through the catalog'ssource_urifor the document or an absolutelookup_pathorsource_path(resolved first; only a.mdunder adocs/rdrdirectory 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) andnx enrich aspects. Existing rows are not rewritten by this change:nx enrich aspects <rdr collection> --all --validate-sample 0re-extracts them (the parse is deterministic; without--validate-sample 0the 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 itscontent_hashis the last writer's, so selectingwhere content_hash=Hmissed 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(thetable_pagetag) now read the document's manifest (doc_idtoget_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'stitle,source_authorandextraction_methodare 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'squality_gate_overriddenaudit 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), andtable_pageis 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_pageis 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_chunkswarning naming the document). A dry run (dry_run, which has no catalog and a throwaway store) keeps thecontent_hashselection. 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 bibselects by the catalog document's manifest, and--backfill-catalogis 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 itstitleandbib_*are the last writer's.nx enrich bibgrouped 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-catalogcopied 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-catalogis 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'scatalog_doc_id, else the filecontent_hashthe indexers stamp, against the document's own: the indexers' chunk rows carry nocatalog_doc_id). That is who last indexed the row, not who wrote itsbib_*: a re-index restampscontent_hashand keeps thebib_*, 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; prefernx 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 backfillregisters 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 deadsource_pathchunk-key reads innx enrich bibare gone (the document's file name comes from its catalog row); the legacy-recovery readers innx catalog backfillkeep theirs, since they recover from chunks that predate RDR-102. Thenx doctorPDF-stub row no longer saysnx enrich bibskips 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 bibleaves it to--backfill-catalog(no API call), and any other such document costs one backend lookup again, so run--backfill-catalogfirst on an old collection (nx dt index --enrichinherits the cost). A document whose attributed chunks disagree on the id (which--backfill-catalogskips) is looked up bynx enrich bibinstead 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;--limitbounds the backend calls only, not the manifest read or the chunk-owner lookup.nx dt index --enrichwarns, prints its indexing summary and carries on when the catalog cannot answer, instead of failing after the indexing.generate_citation_linksnow reads the bib ids from the catalog columns (withmetaas the fallback for old rows), so the citation linksnx enrich bibsays 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 (ornx dt index --enrich, ornx catalog generate-links) createsciteslinks among every paper enriched since nexus-9l2lg, and prints that catalog-wide count, not just the new paper's;nx catalog generate-links --dry-runreports the volume first.nx store expirejudges 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'sttl_daysandindexed_atare 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 ofnx 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_daysof0(the retraction value a permanent re-put writes) and an absent one are permanent; the catalog's0is never handed tois_expired. The clock is a new key,meta.put_at, the time of the note's last put, written withttl_dayson every put (catalog_store_hook_tracked,note_catalog_meta). The entry'sindexed_atis 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 carriesttl_daysbut noput_atcounts fromindexed_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'scatalog_doc_idis that note's tumbler and itstitleis that note's,note_ttl.row_written_by; an.nxexpimport 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; attl_daystoo 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, andnx store expirenames it and exits non-zero (HttpVectorClient.expire_report()returns the counts,expire()raisesExpireIncompleteError).nx store expirealso prints how many notes it retired and how many chunks it deleted; the notes can outnumber the chunks. Theexpires in Ndline on hydrated text and search hits counts from the sameput_at. The in-memoryT3Database.expire(tests only; production isHttpVectorClient) 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 expireno longer retires a note whose owner made it permanent through a client or writer that does not record TTLs in the catalog, and a garbagettl_dayson a chunk row no longer aborts the sweep (nexus-6pfgb.30; client change). A note's ownttl_daysandput_atlive 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.nxexpimport 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'sindexed_atis more than five minutes after the catalog's clock.nx store expirereports such a note on stderr (catalog_ttl_staleonExpireReport) with the remedy: re-put it with this client, with or without--ttl, or delete it withnx 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 astore-getreports 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_expirednow reads a chunk row'sttl_daysthat is not a plain positive number (a string, a list, a bool, NaN) or is pasttimedelta's range (10**10,inf,1e300), and a naiveindexed_atbeside an aware one, as not lapsed; before, such a row raisedOverflowErrororTypeErrorin 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 expirecan no longer retire a permanent note through a later put of its text (nexus-6pfgb.31; client change, works on every engine). The MCPstore_puttitletext 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 untitledstore_putof a titled note's text (with attl) adopted the titled note, blanked its source URI and stampedttl_dayson 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 expirethen 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 butcatalog registercan, is not reused, and an untitled put of its text makes a document of its own, so a later chash-keyednx store deleteof that text finds two owners, calls it ambiguous and leaves the catalog row fornx 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).