Changelog¶
What changed in each release, newest first. The notes are written for the person deciding whether to upgrade; the rules are in docs/launch/changelog-style.md. Lorebase follows semantic versioning. Before 1.0, any minor version may change an interface, and the notes say so.
0.3.1 - 2026-11-06¶
A patch for the first contact with the project: the published images run on arm64, the PyPI packages carry their license and links, a Scribe worker keeps all its slots when the database fails while it records a job and gets a liveness check, the console scripts answer --help, and the quickstart commands point at the Keeper they started. No migration and no breaking change. Three new settings, all optional: scribe.worker.outcome_retry_delay_s, scribe.worker.liveness_file and scribe.worker.liveness_interval_s. The lines link the documentation of each change: the work landed directly on main, without pull requests to link.
Added¶
- Scribe liveness (Scribe): with
scribe.worker.liveness_fileset, the job loop rewrites that file everyscribe.worker.liveness_interval_s(10 s) with its live slots, the jobs running and the time it last reached the store, andpython -m lorebase_scribe.liveness FILEreads it, with the standard library only, and exits 1 with the reason (no file, a stale file, no live slot or, with--max-store-age, a store not reached). The composescribeservice writes/tmp/scribe-liveness.jsonand runs that command as its healthcheck; thescribevalues of the chart show the exec probe - Metric
lorebase.scribe.worker.slots(gauge,stateliveorlost) and the statusunrecordedoflorebase.scribe.jobs(metrics) lorebase-herald --helpprints the variables an MCP host sets (LOREBASE_URL,LOREBASE_API_KEY,LOREBASE_DOMAIN,LOREBASE_BRANCH,LOREBASE__HERALD__TRANSPORT) and the documentation link,--versionthe version; at start Herald logs the transport, the Keeper it calls and the default Domain to stderr, so a host pointed at the wrong Keeper sees it before the first call (MCP)make upprints theexport LOREBASE_URL=... LOREBASE_DOMAIN=supplyline for the CLI and theenventry for an MCP host over stdio, with the port set in.env;make shell-envprints the export line again, foreval "$(make shell-env)"(quickstart)make upchecks every host port the chosen profiles publish before it starts anything and names the variable to change, and refuses to start while a*_BINDvalue in.envwould publish the Keeper of the stack, which runs with authentication off, beyond this machine;KEEPER_PUBLISH_INSECURE=true, in.envor the shell, starts it anyway with a warning (quickstart)make envsetsHERALD_DOMAIN=supplywhile it is unset, so the first call of an MCP host againstmake up PROFILES=mcpneeds no Domain of its ownserver.jsonat the repository root describes Herald for the official MCP registry asio.github.aantenore/lorebase-herald, with the PyPI packagelorebase-herald(run withuvx) and the imageghcr.io/aantenore/lorebase, which now carries theio.modelcontextprotocol.server.namelabel the registry checks; the entry is submitted after a release (releasing)- MCP server: host configurations that run Herald from PyPI with
uvx lorebase-herald, as oneclaude mcp addcommand, a Cursor.cursor/mcp.jsonentry and a VS Codecode --add-mcpcommand - Comparison: DataHub, the Fabric IQ ontology MCP server, Semantica, Doltgres with the Dolt MCP server, TypeDB, mnem and RDF triple stores, each with a linked source checked on 2026-11-06, and a Standards section: LinkML import and export are supported; RDF, OWL, SHACL and JSON-LD are not native and are reached through LinkML's generators
- Images
ghcr.io/aantenore/lorebase,lorebase-mlandlorebase-webforlinux/arm64as well aslinux/amd64: each platform is built on a runner of its own architecture, and the release joins them into one multi-arch index, which it signs and attests with the SBOM (releasing) - PyPI metadata of the ten packages: license
Apache-2.0with the license text in every sdist and wheel, a README, author, keywords, classifiers and links to the repository, documentation, changelog and issues;scripts/release_tool.py checkfails when a package lacks them, and also checksserver.json(its versions, its name and the image label)
Changed¶
- Scribe: an outcome the store refuses to take (a dropped connection, a statement timeout) is written once more after
scribe.worker.outcome_retry_delay_s(1 s); when that fails too the run countsunrecordedand the job runs again once its lock goes stale, as a lost job does (Scribe) - README and the documentation home open with who Lorebase is for and one MCP call with the JSON answer and its Revision; the status note follows, and the 30-second demo stays the second screen
- Quickstart: the commands take the Keeper URL from the line
make upprints (eval "$(make shell-env)") instead of a fixedhttp://localhost:8080; the port table and the port troubleshooting row listGUIDE_HOST_PORT(8095),HERALD_HOST_PORT(8091) and thescale-outports; the MCP section setsHERALD_DOMAINnext toHERALD_ALLOW_ANONYMOUS; the semantic search example writesquery.jsonunder${TMPDIR:-/tmp} - CONTRIBUTING.md, the README and the validation and demo pages state the CI policy as it is: the checks run on demand and for release tags, not on every pull request
- Kubernetes and deployment say that the 0.3.0 images are
linux/amd64only and that 0.3.1 and later are published forlinux/amd64andlinux/arm64 - Releasing: a section's heading reads
X.Y.Z - unreleaseduntil the tag, whose date then replacesunreleased; the page follows the workflow's jobs and adds the MCP registry step - The Release workflow runs the CI workflow on the tagged commit first; images, the GitHub release and PyPI wait for it, so a tag whose checks fail publishes nothing. After the release it publishes the documentation site built from the tag (the
github-pagesenvironment must allowv*tags to deploy) - The compose defaults of Postgres (
pgvector/pgvector:0.8.7-pg16), Ollama, LiteLLM, the OpenTelemetry collector, Jaeger, Prometheus, Grafana and the OIDC mock, and the Helm default of Ollama, are pinned by version and digest instead oflatest,main-stableorpg16; the*_IMAGEvariables still override them, andtests/tooling/test_dockerfile_pins.pychecks the pins - CI: the integration job may run 35 minutes instead of 20, and the BM25 integration job is part of the aggregate
CIcheck - The release jobs no longer keep the checkout token on the runner
.env.examplesetsPOSTGRES_IMAGE,OLLAMA_IMAGE,LITELLM_IMAGE,OTEL_COLLECTOR_IMAGE,JAEGER_IMAGE,PROMETHEUS_IMAGEandGRAFANA_IMAGEto the version and digest pins ofdeploy/compose/docker-compose.yamlinstead of floating tags, so a.envcreated bymake envkeeps the pinned images.
Fixed¶
- A Scribe worker slot ended for good, and nothing reported it, when the database failed while the worker recorded a job's outcome: with the default concurrency the process stayed up with one slot, or none, while the queue grew. The slot now stays on, a slot task that ends with an error is logged and started again after
scribe.worker.poll_interval_s, andlorebase.scribe.worker.slotsshows it (Scribe) lorebasewith no server configured saidpass base_url or set LOREBASE_URL, an argument of the Python client the command line does not have; it now names the three ways a person has (--url,LOREBASE_URL, orurlin a profile of the config file) and the path of that filelorebase-herald --helpand--versionstarted the stdio server, printed nothing and exited 0 when the host closed the pipe- The audit chain heads and the merge attempts queries used a
DISTINCT ONspelling that SQLAlchemy 2.1 deprecates (anSADeprecationWarningin every integration run); they use the dialect'sdistinct_on, and the test suite fails on any such warning from now on - The 30-second demo excerpts in the README, the documentation home and manual setup quoted Revision ids that
examples/merge_demo.pyno longer prints; they are regenerated, andtests/tooling/test_merge_demo.pynow fails when a quoted line differs from the output - Stale documentation: the comparison listed LinkML import and export as roadmap work, the FAQ named the 1M-Record baseline as missing and left out the MySQL/MariaDB and SQL Server Sources, and
examples/README.mdlinked a quickstart anchor that moved;tests/tooling/test_docs_links.pynow checks the links and anchors of the README, CONTRIBUTING.md, SECURITY.md andexamples/README.md scripts/quickstart.sh upresolves the variables of its exposure and port checks the way docker compose does: a variable set in the shell wins over the environment file, then the default.KEEPER_BIND=0.0.0.0 KEEPER_ALLOW_INSECURE=true make upwith a loopback.envno longer starts an exposed Keeper with authentication off, andKEEPER_PUBLISH_INSECUREset in the shell (true or false) wins over the file.
Not done yet¶
- The chart wires no probe for Scribe by default; the
scribevalues show the exec probe to set. - The MCP registry entry (
server.json) is submitted by hand after the release; the registry lists nothing until then.
Upgrade notes¶
No migration. Rebuild or pull the images: the compose scribe service gains a healthcheck that reads the liveness file, with nothing to configure. The new settings have defaults; set scribe.worker.liveness_file and an exec probe to give another deployment the same check.
0.3.0 - 2026-10-30¶
Knowledge that stays correct: a reconciliation queue for contradictions between Sources, corrections that survive Source syncs, document versions, ingestion modes and stewards, Traits and lenses in search and context packs, the merge of text values, and the resolution of words to schema and values with consolidation from use and coverage signals; the web app gains a Reconciliation screen and a passage-by-passage Conflict resolver. Pre-1.0: this minor version changes interfaces; read the breaking changes before upgrading.
Ten migrations (0038 to 0047). New configuration sections keeper.reconciliation, keeper.stewards, scribe.reconciliation, scribe.consolidation, store.values, store.resolve_misses, store.coverage, search.resolve and read.coverage, and new keys store.resolve_misses.caller_key, store.storage.hnsw_m, store.storage.hnsw_ef_construction, search.semantic.iterative_scan and search.semantic.max_scan_tuples. New endpoints POST /domains/{d}/resolve and /domains/{d}/reconciliation; responses add coverage to POST /query and POST /resolve, semantic_truncated to POST /search, can.settings to GET /domains/{d}, and source_key, version, replaces and status to documents. A Source sync no longer overwrites a value someone corrected on the target, and automatic merges into a protected branch wait for steward and role approvals (below, Breaking changes). The lines link the documentation of each change: the work landed directly on main, without pull requests to link.
Breaking changes¶
- Source syncs no longer put back a value someone corrected on the target: an
auto_when_cleanSource that merged silently stops at an open Proposal with aDV1orDV2Conflict when upstream changes such a value, and on its first run after migration0040. Setlocal_edits: source_winson the Source to keep the old behavior (below, Changed and Upgrade notes). - Source syncs with
auto_when_cleanorauto_with_authorityand extraction indirectmode no longer merge into a protected branch whose rules includerequired_approvals_from_stewardsorrequired_roles; the Proposal stays open (merge_blocked: approvals,direct_blocked: approvals). Installations without those rules see no change (below, Changed). - A
textAttribute edited in different sentences on two Branches merges automatically (resolverpolicy:text_merge) instead of raising a Conflict. Settext_merge: falsein the Domain'smerge_policysetting to keep the Conflict (below, Changed). - Semantic search reads the HNSW index at any size: unfiltered and broad-filter lists are approximate where they were exact (recall 0.92 to 0.99 at 300,000 vectors); a one-Kind filter stays exact (below, Changed).
Added¶
- Resolution of words to schema and values (design 8.9, AC-39).
POST /domains/{d}/resolve {text, kinds?, levels?, query_vector?, limit?, branch?, at?}returns ranked candidates the words of a text may stand for: Kinds, Attributes (by name or alias, with their type) and stored values with a ready-to-usewherepredicate and the count of Records holding them, each with its level (exact,synonym,trigram,embedding), score and the words matched, plus the words nothing matched. It never interprets the question, runs a query or calls a model; the embedding level only uses a caller'squery_vectorwhen no other level matched. Candidates never name a Kind or Attribute the caller may not read. Heraldlorebase_resolve, CLIlorebase resolve TEXT, Python clientrecords.resolve(sync and async). Settingssearch.resolve(agents). - Value index:
mat_valuesholds per branch the distinct short values of selected Attributes, kept by triggers onmat_recordsin the writing transaction (migration0045, additive). Enums are selected always, plain strings while their distinct values stay few (store.values); the Scribevalue_indexpass (scribe.scheduler.value_index_interval_s, default 300 s) andlorebase-scribe value-index DOMAIN [--rebuild]decide and backfill the selection. Sensitive and vault values are never indexed. A pass that changes the selection fences only its own Domain's writers (a transaction advisory lock taken shared by the triggers), for at moststore.values.pass_timeout_ms; a schema change that makes an Attribute ineligible removes its values in the same commit. At 100,000 Records resolution answers in 11.2 ms at p95 (levels 1-2); the triggers cost about 90 µs per statement where nothing is held and 0.3 ms per updated Record whose held values change (performance, operations). - Value index (design 8.9): an Attribute's distinct-value limit now holds between
value_indexpasses. The limit ismax_values:hard_max_distinctfor enums andresolvable: true,max_distinct×hysteresisfor plain strings. The write that takes a branch past it marks the Attributeoverflowedin the same transaction: resolution stops returning its values at once, later writes stop indexing it, and the next pass drops it. A schema commit or merge on the default branch applies a new limit in the same commit. Reads also skip a branch past its limit that is not yet marked. - Migration
0047addsmat_value_sizes, the per-branch count of held values kept by the value triggers, andmax_valuesandoverflowedtomat_value_attributes. Passes read the sizes instead of countingmat_values. On upgrade, existing selections gethard_max_distinctand their sizes are counted; the first pass of each Domain counts its sizes again and sets its own limits. Trigger cost: about 90 µs per statement where nothing is held, as before; 0.3 to 0.4 ms per updated Record whose held values change, against 0.29 ms before (measured withtests/perf/resolve_bench.pyon a loaded machine; performance). - Schema:
aliaseson Kinds and Attributes,value_aliases(stored value to other words) andresolvable: true|falseon Attributes; absent by default, so existing schemas keep their hashes. Validation refusesresolvableandvalue_aliaseson types other than string and enum (alone or in arrays), on sensitive and on link Attributes, and value aliases of an enum that name no value (schema.attribute_flag_invalid). The web Modeler keeps them on round trips. - Herald
lorebase_querynow documents counting with"aggregate": {"group_by": attr}and points tolorebase_resolve. - Consolidation from use (design 8.10, AC-40): with
store.resolve_misses.enabled(off by default), Keeper keeps the phrasesPOST /resolvematched to nothing per Domain, branch and phrase (counts, first and last seen, digests of distinct callers; migration0046), off the request path and only for head reads of callers holdingsearch.resolve.misses.permissionDomain-wide; requests opt out withrecord_misses: false, and searches narrowed bykindsorlevelsare not recorded. One caller adds and holds a bounded number of phrases; a full Domain evicts its least missed phrase. Phrases expire afterretention_days(30) and go with their Domain, and so do the vocabulary suggestions quoting them (their share removed, a suggestion left without phrases deleted, decided or not). A Scribevocabulary_consolidationpass (scribe.scheduler.consolidation_interval_s, CLIlorebase-scribe consolidate DOMAIN) turns frequent misses into schema suggestions of the new typesvalue_alias,attribute_alias,kind_alias(near values and names; never for sensitive, vault, derived or non-resolvable Attributes, never a phrase that already names something) and, withscribe.consolidation.llm.enabled,attribute_description(taskschema_consolidation; the model sees names and phrases only). They are reviewed like other suggestions; accepting one opens a schema change Proposal. Vocabulary suggestions have their own cap (schema.suggestions.max_vocabulary_tracked) so they cannot crowd out extraction's; they are withheld from reviewers who may not read their Kind's Records or Attribute's values; an alias that would name two things is refused at acceptance.lorebase-scribe misses purge DOMAINdeletes a Domain's misses and vocabulary suggestions. - Coverage signals (design 8.11, AC-41):
POST /queryandPOST /resolveanswer with acoverageblock (coverage: falseleaves it out; defaultread.coverage.enabled): the share of Records missing each Attribute used, Source freshness (last successful sync,stale_afterexceeded, Records unseen), the phrases unknown in the Domain, and one note per signal. It reads per-Kind statistics of the default branch head (mat_coverage, migration0046) counted by a Scribecoveragepass (scribe.scheduler.coverage_interval_s, CLIlorebase-scribe coverage DOMAIN) from one snapshot per Kind, Records unseen by Sources recounted every pass; the last successful run of a Source is one probe of the partial indexix_source_runs_last_succeeded; masked Attributes and Kinds outside the caller's scope are never described, and other branches and Revisions get no statistics. The two lookups take p95 3 ms at 100,000 Records and 600,000 Source runs. - Python client
CoverageonQueryResultandResolveResult,coverageonQueryRequestandResolveRequest,record_missesonResolveRequest, the vocabulary suggestion types andattribute,value,descriptiononSchemaSuggestion; CLIlorebase queryandlorebase resolveprint the coverage notes; Heraldlorebase_queryandlorebase_resolveend withcoverage:lines. - Web app, Review > Schema suggestions: vocabulary suggestions with the alias, the Attribute, the value they stand for or the proposed description, and the missed phrases as evidence; their Documents cell stays empty, as they come from no document.
store.storage.hnsw_m(default null: pgvector's 16) andstore.storage.hnsw_ef_construction(default 128; pgvector's own default is 64) set the build parameters of the HNSW index of embeddings, for a new installation (migration0001) and for indexesstorage applybuilds later.storage applydoes not rebuild an existing index: installations from before this release keep ef_construction 64 until they rebuild it withALTER INDEX ... SETandREINDEX INDEX CONCURRENTLY(operations). Measured at 300,000 vectors, ef_construction 128 raises mean recall@20 from 0.910 to 0.988 unfiltered and from 0.964 to 0.989 with a 40% filter, same index size, build 66 s instead of 50 s. Documentation now says the semantic list is approximate and gives these figures (search).- Incremental Sources and upstream deletions (design 9.1). Each run of an incremental Source records the Records it read, changed or not (
source_written.last_seen_runandlast_seen_at, migration0043, additive). The new Source settingstale_after(ISO 8601 duration, default null: off; incremental Sources only) makes the reconciliation sweep raise astalecandidate with signalsource_unseenfor each Record the Source owns that no run has read for longer; nothing is deleted. Such a candidate takeskeep_currentor a dismissal (another choice answers 422) and goesobsoleteonce a run sees the Record again (source_seen).scribe.reconciliation.stale.source_unseen(default true, Domain override allowed) turns the check off (Scribe). - Web app, Modeler: the Source editor sets
stale_after; the Reconciliation screen explains thesource_unseensignal. - Document versions and lifecycle (design 9.2, AC-35). An upload may name a
source_key(multipart field or JSON member; at mostkeeper.documents.max_source_key_chars, 1024): the bytes of the key's active version return it (200), other bytes become version n + 1 withreplacesthe previous one, nowsuperseded, auditeddocument.version. Keyless uploads keep their content addressing.GET .../documents?source_key=andGET .../documents/{id}/versionslist versions; documents showsource_key,version,replacesandstatus(active,superseded,retired,purged), and a document's detailsopen_stale_candidates,can_retireandcan_purge. Migration0041, metadata only (operations). - The
document_version_checkjob: for a new version, each value whose current provenance is extraction from an earlier version, and that the new version does not propose again, has its old evidence quote looked for verbatim (white space aside) in the new text; a gone or purged quote raises astalereconciliation candidate with signalevidence_gone({document, from_version, to_version, quote}), masked like evidence for sensitive Attributes. Nothing is retracted and no model is called. Bounded byscribe.extraction.versions.max_values_per_job(5000) with continuations; a Domain turns it off withreconciliation.triggers.document_versions: false(extraction). - Retirement and purge of documents:
POST .../documents/{id}/retirement {reason}(new permissiondocument.retire, held bymaintainerandadmin) runs the check with empty text and stops extraction for good (409document-retiredwhen an upload asks to extract it; extraction jobs naming it fail).POST .../documents/{id}/purge {reason, confirm}(new permissiondocument.purge,adminonly;confirmis the document id) deletes the bytes, parsed text, chunks and cached answers of the document and replaces its evidence quotes in Proposal items, candidates and schema suggestions by[erased], keeping provenance rows; irreversible, auditeddocument.purgein the same transaction (operations). - Python client
client.documents.versions|iterate_versions|retire|purgeandsource_keyonupload,upload_urlandlist; CLIlorebase documents upload FILE --key KEY,lorebase documents list --key KEY,lorebase documents versions ID,lorebase documents retire ID --reason TEXT,lorebase documents purge ID --reason TEXT --confirm ID. - Web app, Documents: key, version and status in the library and on the document page, the versions of a document, its open
stalecandidates linked to the Reconciliation queue, a source key on upload, and Retire and Purge (typing the document id to confirm) for those who hold the permissions. - Read presets (design 8.8).
POST /searchand thesearchandqueryseeds ofPOST /contexttaketraits: a Trait stands for the Kinds implementing it that the caller may read, andkindswithtraitssearch the union. Herald'slorebase_searchandlorebase_contexttaketraitsandlens, the CLI haslorebase search --traitand--lensandlorebase context --traitand--lens. - Lenses:
lenses.{name}in the schema document is a named read preset (description,kinds,traits,relations,depth,include_schema,include_recent_changes,include_lineage,budget_tokens), a schema element with the id prefixlns_that merges and is validated like the others (schema.lens_reference_unknownfor a name the schema does not hold,schema.lens_invalidfor a depth below 0 or a budget below 1).lensonPOST /searchandPOST /contextsupplies the defaults: members of the request win, a lens over the caps is served at them (budget.capped), and the lens's Kinds and Traits limit neighbors andsearchandqueryseeds whilerefsseeds are always kept. A lens is not a permission: Kinds, Traits and Relations the caller may not read are dropped from it, and an unknown lens, or one whose Kinds are all unreadable, is 422lens_unknown; withkeeper.auth.authz.invisible_kinds, a Trait none of whose implementing Kinds is readable is 422unknown_traitlike an unknown one.GET /schemalists the lenses; Herald adds the resourcelorebase://{domain}/lenses/{lens}and aLENSESpart tolorebase_schema(agents). - Vector search that keeps its limit under filters: with pgvector 0.8 or later and
search.semantic.iterative_scan: auto(default) the semantic list runs underhnsw.iterative_scan = relaxed_orderbounded bysearch.semantic.max_scan_tuples(20000) and is ordered again by exact distance; otherwise the index returnssearch.candidates * search.semantic.overfetch_factor(4) nearest rows before the filters. The response sayssemantic_truncatedwhen the scan bound left a list short while more Records match./readyzreports the case in avector_searchcheck (search). - Python client:
traitsandlensonSearchRequestandContextRequest,semantic_truncatedonSearchResult, and thelensessection ofschema.element. - Reconciliation queue (design 10.6): values on which Sources disagree no longer pass unnoticed when a one-sided write replaces another Source's value. Scribe raises candidates of type
contradiction,superseded,staleandduplicatewhile extraction items are assembled, before a Source sync merges, and in thereconcile_sweepjob (scribe.reconciliation.sweep_interval_s, off by default); nothing is changed, hidden or decided because a candidate exists. Stages run cheapest first: identity, values, normalization (units of a shipped table, numeric tolerance, case and punctuation, negation words) and an optional semantic stage (off by default) that marks paraphrasesequivalentand compares stored Record vectors for duplicates. Candidates are fingerprinted by their values, so a dismissal holds until a new value appears (Scribe). - Reconciliation decisions in Keeper under
/api/v1/domains/{d}/reconciliation: list with filters, get,POST .../{id}/resolution(take a claim, keep the current value, a custom value, or a policyauthority,newest_wins,prefer_current,prefer_incoming;dry_run),POST .../{id}/dismissal,POST .../{id}/reopen,POST .../reconciliation:resolvein bulk (at mostkeeper.reconciliation.bulk_max) andPOST .../reconciliation/sweeps(202). Values are written by the Kind'supdate_{kind}with areconciliationprovenance; when the branch needs review the write goes on a newreconcile/{candidate}-{random}branch and Proposal. Duplicates merge into a surviving Record through a Proposal. A candidate whose decision waits in an open Proposal refuses new decisions (409), and a claim or policy decision is refused when a newer value was written since detection. Decisions needconflict.resolveplus what the write needs, and clearance for sensitive or vault values, and are audited (reconciliation.resolve,reconciliation.dismiss,reconciliation.reopen). - Proposal check
reconciliation:warn(default) lists the open candidates of a Proposal,blockstops the merge while contradictions are open (409reconciliation-open). Set bykeeper.reconciliation.merge_check,scribe.reconciliation.merge_checkfor the merges Scribe runs, and the Domain settingreconciliation_check; the stricter one applies. - Valid time: an Attribute may declare
valid_time: true(not on derived Attributes). Its values carry a validity period on their provenance, never in content: from Source mappings (attribute_mapping.{attribute}.valid_from,valid_to), from tier 2 extraction when the dates are in the quote, and from thevalidparameter ofupdate_{kind}. Disjoint periods make asupersededcandidate instead of a contradiction. - Python client
client.reconciliation(sync and async) and CLIlorebase reconcile list|show|resolve|dismiss|reopen. - Web app: a Reconciliation screen (
/{domain}/reconcile) with a tab and count per type, filters by status, Kind, Attribute, source and signal, the claims of a candidate side by side with source, time, period and evidence, decisions with a preview and a required reason, duplicate merges, bulk decisions and sweeps. The Record page marks values with open candidates, the Proposal page links the candidates of its check, and the Source editor names the validity columns ofvalid_timeAttributes. New settingsVITE_RECONCILIATION_PAGE_SIZEandVITE_RECONCILIATION_COUNT_LIMIT(tour). - Metrics
lorebase.scribe.reconciliation.candidates,lorebase.scribe.reconciliation.sweep.durationandlorebase.scribe.reconciliation.open(metrics). - Ingestion modes of extraction (design 9.2, AC-36): a Domain's
settings.extractionchoosesreview_all(nothing accepted at once),doubts_only(the default, unchanged behaviour) ordirect, withauto_accept_threshold,min_confidenceandrulesbysource_keyprefix (longest wins). Tightening always applies; loosening only for the members inscribe.extraction.domain_may_loosen(default[mode]), others are ignored and listed inignored_settingsofGET /domains/{d}.PATCH /domains/{d}takessettings.extraction(domain.settings, auditeddomain.updatewith before and after values). New settingsscribe.extraction.mode(directrefused at startup),domain_may_loosen,direct_merge_protected(extraction). directingestion: Scribe merges an extraction Proposal itself only when every item came from tier 0 and none is pending, the target accepts it and the merge is clean and not blocked by contradictions; any tier 1 or 2 item caps the job atdoubts_only(mode_capped: tier). The job result saysmode,direct_blocked(tier,pending_items,protected,reconciliation,conflicts),direct_mergedandmerged_revision; the merge is auditedproposal.mergewithmode: direct.- Stewards (design 10.7, AC-37):
stewards: [role, ...]ondomainand per Kind in the schema (an undefined role is the warningschema.steward_role_unknown, returned inwarningsbyschema:validate). Proposals and reconciliation candidates carrysteward_rolesand, for callers withprincipal.read, computedassignees(keeper.stewards.max_assignees_listed, 50);GET .../proposalsandGET .../reconciliationtakeassigned=me|any|none. A principal who cannot read a Kind the work touches is never an assignee. Proposals record the Kinds they touch (proposals.kinds, migration0042). - Protection rule
required_approvals_from_stewards(keeper.proposals, Domain setting of the same name; default 0): approvals that must come from assignees, the Proposal needing the larger of it andrequired_approvals; too few assignees answers 409stewards-unavailable. The approval block showsrequired_from_stewards,steward_approvalsandstewards_unavailable. - Protection rule
required_roles(keeper.proposals, Domain setting of the same name; default empty) (design 10.3): each listed RBAC role must have approved a Proposal into a protected branch, through a principal holding it who may review the Proposal. A merge missing one answers 409approvals-requirednaming the roles; Proposal approvals showrequired_rolesandmissing_roles. - Python client
assignedonproposals.list|iterateandreconciliation.list|iterate, steward fields on Proposals and candidates,settingsondomains.update; CLIlorebase proposal list --mineandlorebase reconcile list --mine. Web app: an "Assigned to me" filter and the steward roles and assignees on Review and Reconciliation, the steward approvals on the Proposal page, and the ingestion mode and ignored settings on the Domain settings screen. - A purge no longer races a running extraction job of the same document: the purge and every write of the job (text, chunks, items, candidates, suggestions) lock the document row, the job stops (
cancelled) when the document was retired or purged meanwhile, the purge cancels the queued and running extraction jobs of the document (jobs_cancelledin its audit entry), and a model answer is cached only while its chunk is stored. GET /domains/{d}andPATCH /domains/{d}reportcan.settings: whether the caller holdsdomain.settings, computed by the authorizer (null in the Domain list).- Web app, Settings: the ingestion settings of a Domain (mode, auto-accept threshold, minimum confidence, rules by source key prefix) are editable for callers with
domain.settings, with Keeper's validation errors at the fields they name and ignored values marked; others see the settings read only. - Web app: the Explorer's Kind filter offers the Traits of the schema, and a lens selector (Explorer search, and the Retrieval lab's searches and context pack) appears when the schema declares lenses.
Changed¶
- Automatic merges no longer skip the rules that name who must approve (design 9.1). A Source sync with
auto_when_cleanorauto_with_authorityand an extraction job indirectmode merged into a protected branch, withscribe.sources.auto_merge_protectedandscribe.extraction.direct_merge_protectedon (the default), whatever its approval rules said. Those settings still waive the plainrequired_approvalscount, but when the target's effective rules include steward approvals (required_approvals_from_stewardsabove 0) or required roles (a non-emptyrequired_roles), fromkeeper.proposalsor the Domain setting of the same name, the merge is not performed and the Proposal stays open: the Source run's stats saymerge_blocked: approvals, the extraction job's resultdirect_blocked: approvals, both withblocking_rules. The rules are evaluated by one implementation that Keeper and Scribe share (lorebase_store.approval_rules); Scribe readskeeper.proposalsfrom the shared configuration. Installations without steward or role rules see no change (operations). - Semantic search reads the HNSW index at any size (design 8.8). The nearest vectors of the searched Kinds are taken from
embeddingsalone and joined to the Records, instead of joining first (which sorted every vector of the model): 25 ms against 329 ms p50 unfiltered at 300,000 vectors, 34.5 ms p95 against 513, inside the 200 ms budget. Unfiltered and broad-filter lists are approximate (HNSW recall 0.92 to 0.99 here) where they were exact; a one-Kind filter is exact. Over-fetch (iterative_scan: off) restricts to the Kinds' vectors before its limit. - Web app: a dialog opens with focus on its first control (a confirmation on its cancel button), or on its title when it has none, instead of on its Close button.
- The
include_schema,include_recent_changesandinclude_lineagemembers of a context request are unset by default (a lens may set them) and behave astruewithout one; the Python client'sContextRequestno longer sends them unless given. - Object type code 10 (
schema.lens) joins the stored object types; no migration is needed. - Source syncs: corrections stick (design 9.1, AC-34). A Source now writes only the values that changed upstream since it last wrote them, kept per Source as hashes in
source_written(migration0040, advanced in the transaction that merges the Source's Proposal). A person's correction on the target is no longer put back by the next sync while upstream did not change; when upstream does change that value, the Source's Proposal holds aDV1Conflict whose base is the Source's previous value, and a snapshot run that would delete a Record holding someone else's values holds aDV2, so anauto_when_cleanSource waits for a person in those cases. The new Source settinglocal_editschooses:review(default),keep(resolved at once for the person's value, resolverpolicy:local_edits) orsource_wins. To get the old behavior, setlocal_edits: source_winson the Source. Underauto_with_authority, Attributes whoseauthorityis the Source are overwritten as before. Links a person removed or added are left until upstream changes them. A Record kept by aDV2keeps its Links (the merge restores Links the deleting side removed with it), and a Record someone else deleted that comes back from upstream comes back with its Links. Underkeep, each run also decides again the Conflicts a person reopened by editing a kept value while the Proposal was open. Each local edit is audited assource.local_edit_conflict, and run stats countlocal_editsandseeded. The first run of each existing Source seeds the state; a value it did not write that differs from upstream counts as a local edit that once (operations, Scribe). - Extraction: a value item over a value a person set last (an Operation, or a resolution or reconciliation decision by a principal) stays pending whatever its confidence, with hold reason
local_edit(held_local_editin the job stats) (extraction). - Web app, Modeler: the Source editor sets
local_edits. - Merge: a
textAttribute edited in different places on two Branches now merges automatically instead of being a Conflict. The values are compared by sentence (or paragraph) against the base; edits to different sentences are both kept, the result is recorded as an automatic resolution with resolverpolicy:text_merge, and the Review screen lists it under "Resolved by policy". Edits to the same sentence, or two insertions at the same place, are still a Conflict (neighbouring sentences rewritten on each side merge), which now carriesdetails.text_hunks(the whole text in order, with the merged clean parts and the conflicting ones); its fingerprint is unchanged. Other types (string,uri, enums, numbers,json, lists) and values added on both sides are not affected. To get the old behavior settext_mergetofalsein the Domain'smerge_policysetting, or only for a Kind, Relation or Attribute withtext_merge_elementsortext_merge_attributes;text_merge_granularity(sentenceorparagraph) andtext_merge_max_chars(characters over the three values, default 200000; fixed caps on the number of sentences and on the cost of repetitive text also apply) tune it (design 6.8, 6.10). - Web app, Conflict resolver: a text value that changed in the same sentences on both Branches (a DV1 with
text_hunks) opens as passages: unchanged text folded away, each conflicting passage with Ours, Theirs and Base and a choice (ours, theirs, both in either order, base, or edit in place), a live preview with its length against the Attribute's limits, and "Use merged text" once every passage has a choice.[and]move between passages and1to4and0choose. - Web app, Conflict resolver: string values that moved from the base are shown word by word (added and removed words marked, with text for screen readers), and the Ours and Theirs columns carry the names of the target and source Branches. In the custom editor, "Copy base", "Copy ours" and "Copy theirs" load a side into the field. Saving a resolution opens the next open Conflict. Outside text fields
o,tandbpick a side,eopens the custom editor,sor Enter saves andnmoves to the next open Conflict. Leaving a Conflict with unsaved changes asks first, and "Apply resolutions" asks when Conflicts are still open or the one on screen is unsaved. The bulk choice uses the same names as the resolver ("Take ours", "Restore the Record") instead of the raw choice. - The release workflow can be run by hand (
workflow_dispatch, inputtag) on an existing tag, and publishes the ten packages to PyPI through trusted publishing, each from its own environment (pypi-<package>), when the repository variablePYPI_TRUSTED_PUBLISHINGistrue(release process). - Documentation: the release verification commands name 0.2.3 and the identity of a run started by hand; the README, the quickstart and the MCP guide say how to install
lorebase-cliandlorebase-heraldfrom PyPI.
Fixed¶
- Web app, Modeler: a save also dropped members the editor does not model inside nested objects: a Kind's
search, the Trait implementations of a Kind, Attributeconstraints, Operation parameters and preconditions, and a Source'srelation_mappingentries andtarget_branch_policy. They are now kept as they were. - Semantic search under iterative scans (pgvector 0.8 or later, the default) left
hnsw.ef_searchat pgvector's 40, so each pass of the index scan offered 40 candidates and recall@20 was about 0.92 at 300,000 vectors. The read transaction now raises it tosearch.candidates * search.semantic.overfetch_factor(800 by default, capped at 1000) with and without iterative scans: at 300,000 vectors the 40% filter goes from 0.929 to 0.964, p95 61 ms. The unfiltered list stays at 0.910 with the default index (a query in a region the graph does not reach); see the new HNSW build settings (Added) (performance). docs/reference/configuration.mddid not follow the changed description ofsearch.semantic.overfetch_factor: the page is generated from the comments ofconfig/lorebase.example.yaml, which now carries it.- Web app, Modeler: in live mode, saving the schema dropped the lenses, the
stewardsof the Domain and of Kinds, and any member or section of the document the editor does not model. The editor now carries them through untouched and writes them back as they were. - Semantic search without iterative scans (pgvector before 0.8, or
search.semantic.iterative_scan: off) over-fetched less than configured: an HNSW scan ends afterhnsw.ef_searchrows (40), sosearch.candidates * search.semantic.overfetch_factornearest rows never came back, and a filter keeping 1% of the Records left about 0.2 results of 20. The read transaction now raiseshnsw.ef_searchto the over-fetch count, up to pgvector's limit of 1000: 7.8 results of 20 on 100,000 vectors, flaggedsemantic_truncated(performance). - Web app, mock mode: accepting an attribute schema suggestion reports an update of its Kind, as Keeper does.
Security¶
- Resolution misses: caller digests are HMAC-SHA256 under
store.resolve_misses.caller_key, required whenstore.resolve_misses.enabledis on (at least 32 characters, asecret://reference), so a reader ofresolve_missescan no longer confirm a guessed principal id. Changing the key restarts the count of distinct callers; a caller seen under both keys counts twice until its phrases expire, andlorebase-scribe misses purge DOMAINstarts clean. Migration0047clears the digests stored before it.
Not done yet¶
- Corrections that stick: the last-written state of a Source is not shown in the web app or the API.
- Reconciliation: no queries by valid time (periods classify claims, reads stay at Revisions), no key aliases after a duplicate merge (references to the absorbed key are not redirected), and no MCP tool for the queue. In the web app, tab counts stop at
VITE_RECONCILIATION_COUNT_LIMITand the signal filter and ranking apply to the pages loaded. - Allowed merge origins per protected branch (roadmap).
- Resolution misses are not linked to the next query of the same agent session, and context packs and search carry no coverage block (roadmap).
- The performance targets of the design at 10M Records on the reference hardware, and the 1M-Record neighborhood on branches other than
main, just over budget when the working set does not fit in memory (performance). - The stability guarantees for
/api/v1bind from 1.0 (API stability).
Upgrade notes¶
- Where steward approvals or required roles apply to a protected branch, Source and direct-extraction merges into it now wait for review (above, Changed); give Scribe the same configuration file as Keeper so it sees
keeper.proposals. Existing embedding indexes keep ef_construction 64 until rebuilt (operations). stale_aftersuits incremental feeds that deliver their live rows again within the window; a feed that sends only changed rows flags every unchanged Record oncestale_afterpasses (false positives): leave it null there (Scribe).- Migration
0044buildsix_mat_records_object_hashonmat_records (domain_id, branch, object_hash)per partition withCREATE INDEX CONCURRENTLY(writes continue; no restart; extra space about 40 bytes per row). See operations. - Migration
0038createsreconciliation_candidatesand adds two nullable columns toprovenance(no backfill);0039adds indexes to the candidates. Run the migrations to0039, then restart Keeper and Scribe. Detection is on by default (scribe.reconciliation.enabled) and only raises candidates; the merge check defaults towarn, which blocks nothing. - Migration
0040createssource_writtenandsource_written_pending, empty. The first run of each Source after it is compare-only: every owned Attribute whose upstream value differs from the target's and was not written by that Source (an import, a hand edit, another Source) is aDV1Conflict under the defaultlocal_edits: review, and a snapshot deletion of a Record holding such values aDV2, so anauto_when_cleanSource that merged silently stops at an open Proposal until someone reviews it. Run each Source withdry_run: truefirst (local_edits,seeded). When the target's values are stale copies of upstream, setlocal_edits: source_winsfor that first run (the old behavior, each overwrite audited) and back toreviewafter; otherwise review in bulk withlorebase proposal conflicts PROPOSAL --status open --jsonandlorebase proposal resolve PROPOSAL --file resolutions.json(orPOST .../proposals/{p}/conflicts:resolve), then merge (operations). - Migration
0042addsproposals.kinds(null for existing Proposals until their next merge preview; meanwhile their Kinds are computed when steward roles, assignees or the steward approval rule are read). Withoutsettings.extractionand withoutstewardsin a schema nothing changes;scribe.extraction.mode: directis refused at startup (operations). - Enabling
store.resolve_misses.enablednow requiresstore.resolve_misses.caller_key(at least 32 characters, asecret://reference); the settings fail validation without it. - From 0.2.3: replace the images (or the packages) with 0.3.0, run the migrations to
0047(0041,0043,0045,0046and0047are additive), then restart Keeper and Scribe. Read the notes on0040and0042above before the first Source runs. - From 0.2.0, 0.2.1 or 0.2.2: as from 0.2.3; the migrations include
0037of 0.2.2. - From 0.1.0: follow the upgrade notes of 0.2.0 below, with the images of 0.3.0.
0.2.3 - 2026-10-24¶
A governed bulk delete: delete_records removes many Records of one Kind in one Operation execution, one Revision and one rate-limit token. No migration, no breaking change.
Added¶
- Built-in bulk delete
delete_records(kind,keys, optionalskip_missing): deletes many Records of one Kind in one Operation execution, one Revision and onewritesrate-limit token, instead of onedelete_{kind}call (and Revision) per Record. It is governed like the other built-ins: protected branches and review,dry_run,expected_head,Idempotency-Key, and Kind-scoped grants as fordelete_{kind}; it needs the Kind'sdeletebuilt-in enabled. Links of the deleted Records follow each Relation'son_delete(cascade or restrict) as a single delete does. Unknown keys fail the call (record.not_foundper key) unlessskip_missingleaves them out with warnings. At mostkeeper.operations.max_bulk_delete_keyskeys per call (default 1000). Python clientoperations.delete_records, CLIlorebase record delete KIND [KEY]... [--keys-file FILE] [--skip-missing], and Herald lists it among the generic Operations (design 5.7, operations).
0.2.2 - 2026-10-23¶
Kind-scoped invisibility completes full RBAC: a caller whose grants are narrowed to Kinds no longer learns of the other Kinds, and gets 404 or 422 where it got 403 (keeper.auth.authz.invisible_kinds: false keeps the earlier answers). Lexical search can rank with BM25 through ParadeDB pg_search, opt-in and off by default. Seven fixes for running Lorebase locally: compose rebuilds its images and migrate refuses a database ahead of the code, Keeper settings forwarded from .env, a clear message for reserved Attribute names, herald.http.allow_anonymous, Herald as the compose profile mcp on 127.0.0.1:8091, CORS expose_headers and an additive allow_headers, and the web app README for live mode. Migration 0037 adds functions only.
This release was built and published locally, without the release workflow: the images on GHCR, the checksums and the release assets are not signed with cosign, and the images carry no SBOM attestation (the CycloneDX SBOMs are attached to the release as files). cosign verify does not apply to 0.2.2.
Breaking changes¶
- Kind-scoped invisibility is on by default: principals whose grants are narrowed to Kinds get 404 or 422 instead of 403 for Kinds they may not read, and no longer see those Kinds in the schema, counts, search or context packs. Clients that told a forbidden Kind from an unknown one by the 403 must stop relying on it, or set
keeper.auth.authz.invisible_kinds: falseto keep the earlier answers (design 13.3). - Docker compose builds the backend, backend-ml and web images from the checkout on every
up; setLOREBASE_PULL_POLICY=missingto keep using prebuilt images (deployment).
Added¶
- Kind-scoped invisibility, the last part of full RBAC. A caller whose grants are narrowed to Kinds no longer learns of the other Kinds:
GET /schema(JSON and YAML) and schema elements return the schema without them and without the Relations, path Derivations, derived andrefAttributes, Traits, Operations and Sources that would tell about them, and every request naming one of them answers with the 404 or 422 of a name that does not exist, never a 403. Counts, Link summaries, neighborhoods, search hits and typo corrections, health reports, context packs and agent trace refs cover the visible Kinds only. Such callers can now read the Domain, its branches and tags, so the web app, Herald and the Guide work for them. On by default;keeper.auth.authz.invisible_kinds: falsekeeps the 403 naming the Kind (design 13.3, threat model). - Optional BM25 ranking of lexical search through ParadeDB
pg_search, for searches of very common terms on large Domains:search.backend: bm25ranks with a BM25 index of the existing search vectors, built bypython -m lorebase_store.storage applywithstore.storage.bm25_index: trueon a server that preloadspg_search. Matches, typo corrections, partial matching, Kind filters and masking are those of the default ranking; scores are relative to the best hit. Without the extension or a valid index search keeps the default ranking and/readyzreportssearch_backend: degraded.pg_searchis licensed under AGPL-3.0 and runs only where you choose its image: the compose overlaydeploy/compose/search-bm25.yamland the Helm values fileci/search-bm25-values.yamlset it up; the default stack, chart and images are unchanged. At 1M Records very common words are 20 to 35% faster at p50 and never truncated; rare words are about 3 ms slower, overlays slower, and scans slow down after writes untilVACUUM(search, performance). - Migration
0037:lb_search_terms,lb_bm25_query_validand the security definer functionslb_search_bm25andlb_search_bm25_overlay, granted to the application role like the other read functions. They need nothing of the extension until it is installed. make perf-up PERF_SEARCH=bm25runs the perf Postgres on the ParadeDB image, andtests/perf/bm25.pycompares both rankings, the index build and the commit cost on a seeded database.herald.http.allow_anonymous(defaultfalse): Herald over Streamable HTTP forwards calls without a credential to Keeper without one instead of refusing them, for local stacks whose Keeper runs with auth mode none. Keeper still authenticates each call, so under any other mode they keep failing with[unauthorized](MCP).- Herald in docker compose: the optional profile
mcpruns it over Streamable HTTP on port 8090 in the container, published on127.0.0.1:8091(HERALD_HOST_PORT; 8090 is the web app's), wired to the stack's Keeper, with DNS rebinding protection for127.0.0.1,localhostandherald.make up PROFILES=mcpstarts it with the default services (MCP). - Docker compose forwards documented Keeper settings from
.envto the Keeper services when they are set there:LOREBASE__KEEPER__AUTH__MODE,...__PROPOSALS__ALLOW_SELF_APPROVAL,...__PROPOSALS__REQUIRED_APPROVALS,...__OPERATIONS__DIRECT_COMMIT_ON_PROTECTEDand the...__CORS__origins, headers and credentials. Unset, the YAML file decides as before (deployment).
Changed¶
- Kind-narrowed callers get 404 or 422 instead of 403 for Kinds they may not read, see the cache entries and
ETags of their own projection, and no longer see a Kind whose keys reference an invisible Kind or whose titles or full-text index are computed from one (a searchable count of its Records); derived Attributes reading an invisible Kind, such assupplier_countandsingle_sourcedfor a reader of Parts only, are left out of their Records and of Derivation previews. Agent trace steps of other principals no longer carry result counts, query plans or atruncatedflag for them, Proposal extraction checks no longer count items for callers who do not see every Conflict, and resolving a Conflict outside their Kinds answers 404 like an unknown one. Branch responses leave out the progress of background copies for them. Proposal Conflicts about a reference to an invisible Kind are left out for them, and Record history, lineage, explanations and context packs leave out origins, messages and Proposal titles naming something invisible and the changes that touched only what is left out. - Documentation: the validation record gains the five-day scenario's CI figures, the rounds and findings of the PII vault and outbox security reviews; the README and the documentation landing page describe v0.2.1; the roadmap keeps open only the reference-hardware benchmark and the API stability guarantees (validation, roadmap).
- The standby of the compose profile
scale-outpasses the arguments of its service command to Postgres, so an overlay can add settings to it. - Documentation: the web app README explains live mode against a Keeper on another port or host, through the dev proxy (
LOREBASE_KEEPER_PROXY) or cross-origin (VITE_KEEPER_BASE_URL), and how Keeper allows the app's origin withLOREBASE__KEEPER__CORS__ALLOW_ORIGINSas a JSON list (apps/web).
Fixed¶
- Keeper CORS:
keeper.cors.expose_headers(defaultETag,Lorebase-Revision) makes conditional reads work for a web app on another origin, which could not read theETagand so never got a 304.keeper.cors.allow_headersnow adds to the headers Keeper always allows (Authorization,Content-Type,Idempotency-Key,If-None-Match, the request id, session CSRF and agent trace headers) instead of replacing them, and the trace headers are allowed even with tracing off, since clients send them anyway (configuration). - A Kind Attribute named after a reserved api name (
key,kind,ref,revision) is refused with a message that says why and proposes another name, such as"`key` is a reserved name, choose another (e.g. `code`)", instead ofapi name 'key' is reserved; the schema documentation lists the reserved names (design 5.4). - Docker compose no longer starts a stale local image: the backend, backend-ml and web images are rebuilt from the checkout on every
up(pull_policy: build;LOREBASE_PULL_POLICY=missingkeeps prebuilt images), andmigraterefuses a database that a newer Lorebase already migrated with an error naming the database revision, the newest revision of the code and the fix, instead of Alembic'sCan't locate revisionand a Keeper that restarts in a loop (deployment).
Upgrade notes¶
keeper.cors.allow_headerslists further headers only; its default is now empty. A configuration that repeats the earlier default keeps working, and one that listed fewer headers now allows the required ones as well.- Clients of Kind-narrowed principals that relied on 403 to tell a forbidden Kind from an unknown one now get 404 or 422 for both. Set
keeper.auth.authz.invisible_kinds: falseto keep the earlier answers. - Migration
0037creates functions only and runs on any Postgres; apply it as usual. Search is unchanged unless you setsearch.backend: bm25and build the index. - From 0.2.1: replace the images (or the packages) with 0.2.2, run the migration to
0037, then restart. - From 0.2.0: as from 0.2.1; the notes of 0.2.1 add nothing to do.
- From 0.1.0: follow the upgrade notes of 0.2.0 below, with the images of 0.2.2.
Not done yet¶
- The performance baseline on the 1M-Record fixture: the neighborhood on branches other than
mainis just over budget when the working set does not fit in memory (performance). - The performance targets of the design at 10M Records: the 2-hop neighborhood, full-text search (searches of very common words above all), Record history and lineage are over budget, and a commit of 1,000 changes takes more than 2 s (performance).
- The stability guarantees for
/api/v1bind from 1.0 (API stability). - Packages on PyPI: the wheels and sdists are attached to the GitHub release.
- Signed images and assets: 0.2.2 was published without the release workflow (see above).
- BM25 search at 10M Records is not measured (
make perf-up PERF_VM=16g PERF_SEARCH=bm25, thentests/perf/bm25.py --database perf_seed_10m_zipf), andstorage applydoes not tune autovacuum for it: with BM25 on and frequent writes, set a lowerautovacuum_vacuum_scale_factoron themat_recordspartitions yourself (performance).
0.2.1 - 2026-10-23¶
Patch release for the storage profiles of 0.2.0. python -m lorebase_store.storage apply now converges when commits run during the lineage rewrite, the downgrade of migration 0036 no longer deadlocks against commits, and a repeated apply skips the full lineage scan once a pass is recorded complete (--verify forces it). The performance documentation replaces the 10M-Record storage profile estimate with measured numbers. No migration, configuration key or API response changes.
Added¶
make perf-upstarts the Postgres of the performance harness without running a benchmark;PERF_VM=16gsizes its memory for a Docker VM of 16 GiB (shared_buffers6GB,effective_cache_size12GB) instead of the defaultPERF_VM=8g. EachPERF_PG_*value can still be set on its own; these are harness settings, not product defaults (performance).python -m lorebase_store.storage apply --verifyscans every lineage map row again and resets what is recorded (performance).
Changed¶
storage applyno longer scans every lineage map row again once a rewrite has finished: it records the pass per partition instorage_taskswith a watermark, and a later run with the same profile only looks at the rows written since. At 10M Records the full scan took 13.5 minutes even with nothing to rewrite. Rows written while the triggers were turned off out of band, or frozen byVACUUMbefore a run looked at them, are only found by--verify(performance).- The storage profiles at 10M Records are measured instead of estimated (Docker VM of 16 GiB): the hot set goes from 57 GB to 44.1 GB in
balancedand 43.3 GB incompact, the cold 2-hop neighborhood from 122 to 100 ms at p50 (96 incompact), and a commit of 1,000 Part updates takes 2,711 ms at p50 inbalancedand 2,332 incompactunder the same load (performance, validation).
Fixed¶
storage applyno longer leaves lineage map rows a commit held during the rewrite in the other form: the page ranges they are in are rewritten again after the pass, and while rows stay held the step is deferred (exit status 3) and the next run finishes them (performance).- The downgrade of migration
0036takes the locks ofmat_recordsandmat_linksup front, in the order commits write them, each wait shorter thandeadlock_timeout: a commit waiting for it is never chosen as a deadlock victim, and neither is the downgrade.
Not done yet¶
- The performance baseline on the 1M-Record fixture: the neighborhood on branches other than
mainis just over budget when the working set does not fit in memory (performance). - The performance targets of the design at 10M Records: the 2-hop neighborhood, full-text search (searches of very common words above all), Record history and lineage are over budget, and a commit of 1,000 changes takes more than 2 s (performance).
- Full RBAC: Kind-scoped invisibility in queries (roadmap).
- The stability guarantees for
/api/v1bind from 1.0 (API stability). - Packages on PyPI: the wheels and sdists are attached to the GitHub release.
Upgrade notes¶
- From 0.2.0: replace the images (or the packages) with 0.2.1 and restart; there is no migration and no configuration to change. If an earlier
storage applystopped or left lineage map rows in the other form, run it again as the maintenance role: it finishes them, and it exits with status 3 while commits still hold rows, in which case run it again later. The first run of 0.2.1 makes the full lineage pass once and records it; later runs skip it. - From 0.1.0: follow the upgrade notes of 0.2.0 below, with the images of 0.2.1.
0.2.0 - 2026-10-23¶
Second pre-alpha release. Records now carry their history and lineage, values tagged pii, confidential or restricted are masked and vault values can be erased, Scribe computes embeddings for semantic and hybrid search, path Derivations work, storage profiles decide what the database keeps besides the rows, and Guide, an optional exploration assistant outside Keeper, reads a Domain with the caller's own credential. The web app signs in through Keeper and its Modeler edits Operations, Derivations and Sources, and its copy no longer names the data of any one Domain. Pre-1.0: this minor version changes interfaces; read the breaking changes before upgrading. The database schema moves from migration 0012 to 0036, read.history.lineage_max_changes is removed, Scribe and Guide run on a new image ghcr.io/aantenore/lorebase-ml, and POST /search adds truncated_candidates. The lines link the documentation of each change: the work landed directly on main, without pull requests to link.
Breaking changes¶
- Webhook signing keys are sealed by default: Keeper refuses to create a webhook unless
webhooks.encryption_keyis set (webhooks.allow_unsealed_keysallows it for local development only),make envgeneratesLOREBASE_WEBHOOK_KEYfor the compose stack, and the Helm chart requiressecrets.values.webhookEncryptionKeyor an existing Secret entry. - The Helm chart no longer runs Keeper and Scribe as the owner of the tables. As on compose, it takes two DSNs:
db-maintenance-dsn(secrets.keys.dbMaintenanceDsn, the owner) for the migration Job and fordatabase.maintenance_dsnof Scribe and Keeper, anddb-dsnfor the application role (database.appRole.name, defaultlorebase_app) that Keeper and Scribe connect as, so row-level security, the append-onlyaudit_logand the ban on DDL apply to them. Adb-rolesinit container of the migration Job creates the role when missing (database.appRole.create, needsCREATEROLE) or waits for one created elsewhere, refusesSUPERUSER,BYPASSRLSor the owner, and applies the grants of the compose script; the migration runs withdatabase.application_role. With the in-cluster Postgres the operator manages the role (postgresql.cluster.roles) with the password ofsecrets.values.dbAppPassword. The chart refuses one user for both DSNs unlessdatabase.allowSharedRole: true(development). Upgrading: adddb-maintenance-dsnwith the old DSN and pointdb-dsnat the application role; an in-cluster release upgrades in one step (next line) (Kubernetes). - Kubernetes, in-cluster database: one
helm upgradenow adds the application role (kubernetes). Apg-rolespre-upgrade hook Job declares the roles ofpostgresql.cluster.roleson the CloudNativePG Cluster and waits for the operator before the migration Job runs; upgrading a release that used one DSN no longer takes two upgrades. It needs access to the API server when egress is restricted (networkPolicy.egress.kubernetesApi);database.setup.clusterRoles.enabled: falseturns it off. - Scribe refuses to start when
scribe.worker.heartbeat_interval_sis more than half ofstore.jobs.stale_after_seconds. Jobs run at least once: a job is run again after its worker is lost, so handlers must tolerate a second run (the built-in ones do). - Reading values of Attributes tagged
pii,confidentialorrestrictedneedspii.read,confidential.readorrestricted.read; other callers see them masked (design 10.2). Feeding vault values through imports and Sources needs the new Domain permissionpii.import(onlyadminamong the built-in roles), and vault key rotation needsvault.manage(installation administrators only). Give the read permissions through a custom role inkeeper.auth.authz.custom_roles. - Sensitive Attributes are left out of full-text search for every caller (see Changed): a search that matched a sensitive word no longer finds the Record.
read.history.lineage_max_changesis removed (lineage no longer reads a window of changes). Configuration files refuse unknown settings: delete it.lorebase admin erase --value Vrefuses a literal value, which stayed in the shell history: use--value -or--value @FILE. A value erased before is stored erased when written again;vault.erased_values: allowkeeps the old behaviour.- Lexical search ranks a different set of matches by default: within
search.common_term_docs(2,000) every match is ranked, beyond it the firstsearch.common_term_candidates(1,000) in index order, andPOST /searchanswerstruncated_candidates: truewhen that cut applies. Before, the firststore.reads.search_candidates(1,000) matches were ranked for every query.search.common_term_docs: 0keeps the old behaviour. Keeper needs migration0035. - Compose and Helm run Scribe and Guide on the new image
ghcr.io/aantenore/lorebase-ml(844 MB against 453 MB; fastembed and a baked embedding model). Deployments that mirror or pin images need it too; HelmimageMl.repository: ""keeps Scribe and Guide onimage(kubernetes). - Guide needs a writable state directory while its root file system is read-only: compose mounts the volume
guide-dataat/var/lib/lorebase/guideand Helmguide.persistence(an emptyDir by default); elsewhere the default is~/.local/state/lorebase/guide, so mount a volume there or setguide.mode_memory_file: null(guide). - Compose:
make up,make logs,make down,make clean-stackandscripts/quickstart.shadd the files listed inCOMPOSE_OVERLAYSafter their own and stop before compose runs when one does not exist;make downstops every profilemake upcan start (quickstart). - Migration
0036(shared lineage maps) leaves the lineage maps of an existing install inline untilpython -m lorebase_store.storage applyruns as the maintenance role, so processes of 0.1.0 keep reading them during a rolling upgrade; the space comes back only withapply --compact --reindex(store schema notes). A new install shares maps from the start. store.storage.profile(compact,balanced,fast; composeLOREBASE_STORAGE_PROFILE, Helmconfig.store.storage.profile) decides which secondary indexes exist. The default builds the same indexes as before;compactdrops the GIN index on Record values, the indexes ofindexed-only Attributes and the lexicon's trigram index whenstorage applyruns (performance).- Web app: the
supplypresets and dataset-specific placeholders are gone, example questions come only fromdomain.examplesof the schema, and the "planned screen" placeholders (Explorer, Modeler, Review, Sources, Settings, the Record page's Run Operation and the Canvas diff overlay tab) are removed. The Retrieval lab no longer embeds the Explore panel; it links to Explore. Every user-facing string moved toapps/web/src/messages, so local text patches must move with it.
Added¶
- Search ranks every match only when it can afford to (design 8.4, performance). Lexical search without filters reads the word lexicon's counts of the query's lexemes, which bound its matches: within
search.common_term_docs(2,000) every match is ranked; when every term is more common, only the firstsearch.common_term_candidates(1,000) matches in index order are, andPOST /searchanswerstruncated_candidates: trueso that callers narrow the query (Herald and Guide say so to the agent). Before, at moststore.reads.search_candidates(1,000) matches were ranked for every query, without saying when that cut the ranking.search.common_term_docs: 0keeps that behaviour. Measured at 1M and 10M Records with realistic text, 20,000 and 5,000 raised the p95 of title-word searches 3.4 times at 1M and 4.4 times at 10M, hence the lower defaults. Migration0035adds the security definer functionslb_search_boundedandlb_search_bounded_overlay, which report the cap. values: falseonPOST /neighborhood(agents): nodes come back as Record headers (kind,key,title) and edges as Link endpoints, for callers that need the structure only. The Python client takesvaluesonNeighborhoodRequest; Herald'slorebase_neighborhoodasks Keeper for it unless itsvaluesis true (its default leaves values out, as before, now Link values too); Guide'sneighborhoodtool takesvalues(default true).--vocabulary zipfforexamples/supply/scripts/make_supply_data.py: names, titles, summaries and reasons drawn from 40,000 terms with Zipf frequencies plus product codes, the rest of the dataset unchanged. The 1M and 10M benchmarks use it (seed databasesperf_seed_1m_zipfandperf_seed_10m_zipf;PERF_VOCABULARY=demomeasures the earlier ones);tests/perf/focus.pycompares search with and without the lexicon-sized ranking and the neighborhood with and without values on a seeded database.- Benchmarks at 1M and 10M Records (performance):
make perf-1mandmake perf-10mstart a Postgres of their own (tests/perf/compose.yaml, compose projectlorebase-perf), seed the dataset once into a kept database and measure it in place;make perf-downdeletes it. The 10M fixture loads a tenth through the commit pipeline and copies its materialized rows with renumbered keys (--replicas); an interrupted load resumes. The harness also times the agent trace through Keeper (--trace-iterations) and can bound the measured branch copy (--copy-batches,--no-pair-copy). COMPOSE_OVERLAYS(in.envor the environment): extra compose files, space separated, thatmake up,make logs,make down,make clean-stackandscripts/quickstart.shadd after their own, somake up PROFILES=assistantcan start Scribe with a hosted model and its key file (deploy/compose/hosted-model.yaml) and an overlay of your own (quickstart, inference providers). A listed file that does not exist stops the command before compose runs.make logs(SERVICES="scribe guide"narrows it) follows the logs of the stack.- Job metrics
lorebase.jobs.reclaimed(by type and outcomerequeuedorfailed) andlorebase.jobs.released(by type), and the alertLorebaseJobsReclaimed(operations) when running jobs lose their worker. - Guide and Scribe embed text out of the box in compose and Helm. They run on a new image,
lorebase-ml(targetbackend-mlofdeploy/docker/backend.Dockerfile;ghcr.io/aantenore/lorebase-mlin releases): the backend image plus fastembed (ONNX Runtime, no PyTorch) and the embedding modelEMBEDDING_MODEL(defaultBAAI/bge-small-en-v1.5) downloaded at build time, soPOST /embedand Scribe's embedding jobs work offline with the read-only root file system. It is 844 MB against 453 MB for the backend image, so Keeper, the migrations and Herald stay on the small one. Helm:imageMl(emptyimageMl.repositoryputs Scribe and Guide onimage). Thesupplyexample schema givesPartandSupplieran embedding template, so semantic search over the sample returns hits once Scribe has run its first pass (quickstart). make up PROFILES=assistant(ormake up-assistant) starts Guide and Scribe with the quickstart stack through the new overlaydeploy/compose/assistant.yaml, which points the web app at/guideand lets Guide accept calls while Keeper has no authentication.make downnow stops every profilemake upcan start.make example-documents(scripts/quickstart.sh documents) extracts the contract C-3099 and the four example documents with the rules tier and lists the schema suggestions. One document supports each suggestion once, belowschema.suggestions.min_support(2), so a single upload listed nothing; five documents listends,sole_source_partsandbuyer(extraction).- Record lineage names the Revision behind every value, however many changes came after it (agents): materialized Records keep that Revision per Attribute (migration
0032,mat_records.lineage_revisions), and rows written earlier fall back to the branch history. Lineage at a branch head is one statement, about 25% faster at 100k Records (performance).GET .../records/{key}/lineagetakesatand answers from a snapshot of that Revision, like Record history; so dolorebase record lineage --at, the Python client and the MCP toollorebase_lineage. - The Modeler previews Derivations and Source expression mappings before they are saved (tour).
POST /domains/{d}/derivations:previewchecks the edited schema like a schema change and evaluates a candidateexpressionoraggregateDerivation on sample Records of its Kind, with Record-read permissions and masking (read.derivation_preview);POST /domains/{d}/sources:preview(schema.read) evaluates a Source'sexpressionmappings on the rows sent (read.source_preview_max_rows). Nothing is written. Scribe now maps Attributes byexpression(CEL over the row,row["name"]for any column) instead of refusing them. Python client:preview_derivation,preview_source. - Path Derivations are now followed wherever Relations are: neighborhoods (when named in
relations), Link counts of a Record, Operationlinks(),links.<path>in Derivation expressions andaggregateDerivations, which are updated incrementally when a Link of any step changes. Paths crossing a Kind the caller may not read are left out of neighborhoods and Link counts.mat_schema.path_fanoutrecords thestore.traverse.path_fanouteach branch was computed with; a commit or a starting job worker that finds a different value rewrites the materialized paths and the derived values reading a path (design 5.8). - Structured output modes are remembered per chat endpoint across restarts (inference providers). Scribe keeps them in the table
inference_endpoint_modes; Guide keeps them in a locked JSON file (guide.data_dir,guide.mode_memory_file). Entries are keyed by an HMAC of the endpoint, model and output settings (inference.mode_memory_keyis mixed in when set) and expire afterinference.mode_memory_ttl_s(7 days).lorebase-scribe modes clearandlorebase-guide modes clearforget them at once. - Schema suggestions from the CLI:
lorebase schema suggestions list|accept|reject|merge(extraction).merge(alsoPOST /domains/{d}/schema/suggestions:merge,schema.write, clientmerge_suggestions) folds near-duplicate Attribute suggestions already stored, with the same similarity as new ones. A Kind suggestion is no longer hidden by a same-named Attribute unless that Attribute is free text no Relation models and the evidence shows no independent entity (schema.suggestions.kind_independent_instances,kind_independent_attributes). An optional dedicated discovery pass (scribe.extraction.discovery.llm_pass, off by default) proposes Kinds and Attributes through theinference.llm.tasks.schema_discoveryroute. - Reads at a past Revision (
at) support filters, ordering, includes, aggregates, Link and lineage summaries, traversal, neighborhoods, search, Record history, health reports, context packs and explanations (design 8.6). They are served from a snapshot of the Revision, materialized on first use with the code of a branch rebuild and shared by later reads, so they answer what the head answered when that Revision was the head.read.time_travel.snapshot_max_entries(100000) bounds the size of a snapshot, larger Revisions are refused with 422time_travel, andread.time_travel.snapshot_max_per_domain(4) how many are kept. History, explain and the health routes takeatas a query parameter, traverse, neighborhood, search and context in the body. - An overlay for trying browser sign-in on the compose stack (quickstart):
deploy/compose/oidc-mock.yamlstarts a mock OpenID Connect provider in Keeper's network namespace, turns onkeeper.auth.sessionsand puts the web app in session mode (WEB_AUTH_MODE=session); setLOREBASE_SESSION_KEYin.env. The provider signs in anyone: local use only. One sign-in, session, CSRF-protected sign-out and expiry were checked through thewebservice (validation). guide.http.default_origins, the browser origins Guide accepts whileallowed_originsis empty. The compose file sets it to the origins of itswebservice, soGUIDE_ALLOWED_ORIGINS=[]now behaves as unset (Guide logs a warning) instead of refusing the web app.- A values schema for the Helm chart (
deploy/helm/lorebase/values.schema.json): every value ofvalues.yamlis typed, settings with a fixed set of values are enumerated, and unknown keys are refused except underconfigandpostgresql, sohelm lint,template,installandupgradereject a misspelled key or a wrong type. CI lints and renders theguideprofile and checks that the schema refuses bad values. - Connectors: SQL Server
querySources can end inORDER BY(Scribe appendsOFFSET 0 ROWSinside the derived table), androwversionwatermarks are kept as hex in the run checkpoint and bound as bytes.POST /domains/{d}/schema:inferaccepts named MySQL, MariaDB and SQL Server sources (mysql,mssql), read through a bounded sample under the same allowed-source rules and profiled like records (newlorebase-keeper[mysql]andlorebase-keeper[mssql]extras,store.profiling.sqlsettings). JSON uploads can hold their records under a key with therecords_pathform field (Python clientinfer_files(records_path=...), web "Start from data"). The newPOST /domains/{d}/schema:preview(schema.write, nothing stored; clientpreview_files) returns the first rows of uploaded files, and the web Source preview uses it for Parquet files. - Explore in the web app (guide): a screen next to the Retrieval lab, and the same panel under the lab, that asks Guide a question about the Domain and shows each event of
POST /exploreas it streams in (reasoning notes, tool calls with compact arguments, result summaries and counts, errors, the answer and the totals). The request carries the caller's own credential (Bearer token, or the session cookie and CSRF header when Guide is served from the app's origin;guideUrlmay be a path such as/guide). Stop aborts the request, a stream longer thanVITE_EXPLORE_TIMEOUT_MSis stopped, and a 429 waits forRetry-After. Model output is rendered as plain text only; citations become links to Record pages when their Kind and key are valid, "View trace" opens the session in Agent activity, and the cited Records can be drawn on the Canvas. ThesupplyDomain has five preset questions. Against the mock API the panel plays scripted explorations. New settingsVITE_EXPLORE_MAX_STEPS,VITE_EXPLORE_TIMEOUT_MSandVITE_EXPLORE_MOCK_STEP_MS. - Guide, an optional exploration assistant outside Keeper (guide). The new service
lorebase-guide(packagelorebase_guide) answersPOST /explorewith a stream of server-sent events: a small read-only agent reads the Domain through Keeper (schema, search, query DSL, Record, links, neighborhood, traversal, history, lineage and context packs), streams each tool call, a short result summary and the model's reasoning notes, and ends with an answer citing the Records it was shown, or "Not found in the graph." Every Keeper request carries the caller's own credential (Bearer token or API key, or the web session cookie with its CSRF token, checked against Keeper), so RBAC and masking apply exactly as for the user; Guide holds no credential and has no write tool. Each exploration is an agent trace session (X-Lorebase-Session, tool header, reasoning posted as notes). Budgets for steps, tokens per call, tokens per exploration and wall-clock time, per-credential rate and concurrency limits, and an Origin allow-list are in the newguidesettings.POST /embedreturns the vector of a query or document text with the configured embedding model and prefixes, the same vector Scribe stores for the same document text. Besides Scribe, Guide is the only package that may uselorebase_inference; the import contracts keep Keeper and Herald model-free and forbid them to import Guide. It runs from the backend image (compose profileassistant, Helmguide.enabled, off by default), and the web app reads its address from the runtime configurationguideUrl(VITE_GUIDE_URL). - The Python client refuses empty,
.and..path segments (Domain ids, Kind names, Record keys, relation names) and branch names with such parts, raisingValueErrorbefore any request: an HTTP client resolves them, which sent the request to another route. - A quickstart that works from a fresh clone (quickstart).
make envwrites.envwith generated database passwords,make upbuilds the image from the checkout and waits until Keeper is healthy,make example-dataloads thesupplysample and merges it intomain,make downandmake clean-stackstop the stack and delete its data. The quickstart adds an agent connected over MCP, a real model (Ollama in compose or on the host, or a hosted endpoint whose key is mounted from a file bydeploy/compose/hosted-model.yaml), cleanup, a troubleshooting table and measured times..env.examplehasCOMPOSE_PROJECT_NAME, so two stacks on one machine no longer share containers and volumes, and Scribe andinference-checkresolvehost.docker.internalon Linux too. - Ranked partial matching in lexical and hybrid search (design 8.4). When
qas written (every term) finds fewer thansearch.partial.min_resultsRecords (1), Records holding some of its terms follow, after every full match: the words and quoted phrases ofqjoined by OR, negations kept for each, stop words ofsearch.partial.stop_words(english) left out, scoredweight * (ts_rank_cd + term_bonus * terms held)with typo corrections applied per term. Each hit reportsmatch(allorpartial, absent for hits found by vector only); the field is new and optional. Descriptive queries such as "ring that stops oil leaking" found nothing before, so hybrid search ranked them by the vector alone; the O-rings now come first lexically too (validation). Measured at 100k Records in performance. - Chat endpoint options for hosted providers (inference providers):
inference.llm.headerssends extra HTTP headers (values acceptsecret://references, for example ananthropic-workspace-id),extra_bodymerges extra request fields (reasoning_effort,thinking, ...),json_schema_strictsetsresponse_format.json_schema.strict, andtemperature: nullleaves the temperature out for models that refuse it. All four also work per task ininference.llm.tasks; a task on anotherbase_urlnever inherits the headers. Header values are scrubbed from error text like the key. Anthropic's OpenAI-compatible endpoint and local Ollama models were run against the example documents; setup and results are in validation. - Agent trace (agent trace,
keeper.trace, off by default). Requests that opt in (X-Lorebase-Trace: on, or a listed principal or API key, ortrace_all, which leaves out the web app's own requests) become steps of the session they name (X-Lorebase-Session) or, without one, of an inferred session (same principal, credential and user agent, calls less thaninferred_session_idle_sapart, markedinferred) in the new tableagent_trace_steps(migration0023): tool (X-Lorebase-Tool), route, status, latency, response cache use, the query as structured data (filter values kept only on Attributes that are not sensitive,capture_filter_values), result counts, search corrections, context pack budget and the Records and Links the answer touched, never values; withX-Lorebase-Trace: debugand the new permissiontrace.explain, query plan summaries. Agents attach notes withPOST /api/v1/trace/sessions/{id}/notes;GET /api/v1/trace/sessionsandGET /api/v1/trace/sessions/{id}list sessions and replay one. A principal sees its own sessions; the new permissiontrace.read(held byadmin) shows other principals' steps in a Domain, with their query and note text only to readers holding every sensitivity clearance there (withheldotherwise). Steps are written off the request path in batches, capped per session and deleted afterretention_days(7). Herald forwards the session id of the MCP client or session (none when there is none), the tool name and atraceparenton every Keeper call; the Python client takessession_id,traceandtooland has atraceresource; the CLI haslorebase trace list|show; the web app has an Agent activity screen with the timeline and the touched subgraph on the Canvas, step by step. - Retrieval lab in the web app (retrieval lab): one question, two parameter sets side by side (mode, weights, thresholds, limits), lexical results with scores and corrections, semantic and hybrid ones when a query vector is pasted or an embedding service is configured (
guideUrlin the web app's runtime configuration; the browser calls it, never Keeper), the context pack an agent would get with why each entry is in it and its tokens against the budget, and the involved Records on the Canvas.POST /searchtakestuning(lexical_weight,semantic_weight,rrf_k,trigram_weight, and the work boundstypo_candidatesandcandidates, which may only be lowered, andtrigram_threshold, which may only be raised) in place of thesearchsettings for one request. - Schema suggestions from document extraction (extraction). Extraction jobs report what they see outside the schema:
**Label:** valuefields and table columns no Attribute matches, headings naming a thing by a code no Kind knows, spans and relations of the newscribe.extraction.discovery.span_labelsandrelation_labels, and, at tier 2, anunlistedlist in the answer schema plus records, values and links outside the schema, all with verified evidence. Observations are matched against the schema (normalized singular names, synonyms, similarity), never kept when they match a sensitive Attribute, kept without evidence when the quote holds a sensitive value, and aggregated per Domain by fingerprint into suggestions with support, documents, examples, samples and, for Attributes, the type schema inference gives the samples; extracting a document again does not count it twice.GET /api/v1/domains/{domain}/schema/suggestionslists them (permissiondocument.read; pending ones fromschema.suggestions.min_support, at mostmax_suggestions) andPOST .../schema/suggestions:deciderejects, resets or accepts them: accepted suggestions become one schema change set on a new branch (keeper.schema_suggestions.branch_prefix) proposed into the default branch, new Kinds and Relations as drafts. The web app has a Schema suggestions tab in Review and the Python clientschema.suggestions,iterate_suggestionsanddecide_suggestions. New settingsschema.suggestions,scribe.extraction.discoveryandkeeper.schema_suggestions; migration0021adds theschema_suggestionstable. The tier 2 cache key changes when discovery is on, so the first run after upgrading calls the model again. lorebase schema import --from linkml --applymerges into the current schema of the Domain instead of replacing it: Operations, Derivations, Sources and the fields LinkML cannot carry (derivation,authority,indexed, ...) are kept, elements match by id or name, and Kinds, Attributes, Relations and Traits missing from the LinkML file are kept with a warning unless the new--pruneflag removes them;--dry-runshows the resulting change set (LinkML).- LinkML schema import and export (LinkML).
lorebase schema export --format linkmltranslates the schema of a Domain, or a local document with--file, into a LinkML schema: Kinds become classes, Traits mixin classes, Attributes class attributes with their range,required,identifier, constraints andifabsent, enums LinkML enums, and each Relation a pair of slots linked byinversewhosemultivaluedgives the cardinality. Element ids, sensitivity tags, search settings and the types LinkML lacks travel aslorebase.*annotations, Operations, Derivations and Sources as schema annotations, so importing the export gives back the same schema.lorebase schema import FILE --from linkmlprints the Lorebase document (or writes it with-o),--applyand--dry-runsend it to a branch likeschema apply; what it leaves out (Operations, Derivations and Sources,is_ainheritance, which it flattens, abstract andtree_rootclasses, inlined objects, rules,any_of, enum meanings, custom type constraints) is reported as warnings with their path, and classes without slot descriptions or a title attribute are imported asdraftKinds. The translation needs PyYAML, in the new optional extralorebase-cli[linkml]. - Path Derivations work (design 5.8). A
pathDerivation is a virtual Relation (A -r1-> B -r2-> Cread asA -> C) usable by itsapi_nameorinverse_api_namewherever a Relation name is accepted from a Kind: Links of a Record and of a Relation,viaandcountpredicates,includeand traversal. By default its Links are composed at read time from the steps' Links (overlays and row-level security included); every later step follows at moststore.traverse.path_fanoutLinks (100) per intermediate Record, smallest far keys first. Withmaterialized: truethe Links are stored inmat_linksunder the Derivation id and kept current in the commit transaction: only the start Records a changeset can affect are recomputed (abovestore.materialize.derivation_max_incremental_records, the whole path), schema changes and rebuilds rewrite them, andverify(..., derived=True)checks them. Both modes give the same Links, also at past Revisions, where they are composed from the tree. Following a path needs read access to every Kind it crosses. Path Links carry no values. - Trait queries at a past Revision (
at):POST /querywithtraitlists the Records of every implementing Kind from the tree, in the head's order and with the head's cursors; filters, ordering by other fields, includes and aggregates stay refused there (422time_travel), as for Kinds. - MySQL/MariaDB and SQL Server Sources: the types
mysql_table,mysql_query,mssql_tableandmssql_queryread a table or a query like the Postgres ones (snapshot or incremental with a watermark compared in SQL, batches offetch_rows, connection by allowedsecret://reference,statement_timeout_msandconnect_timeout_sin the newscribe.sources.mysqlandscribe.sources.mssqlsettings) and can be profiled for schema inference. The drivers are optional extras and a run without one fails naming it:lorebase-scribe[mysql](asyncmy; the session is read-only) andlorebase-scribe[mssql](pymssql in worker threads, no ODBC driver to install; use a login that can only read, since SQL Server has no read-only transaction). Schema validation asks the new query types for a query, and the web Modeler lists the types and keeps them on a round trip (scribe). - Operation side effects (design 10.5, agents). A stored Operation's
side_effects(unique snake_case names of at most 64 characters, now checked by schema validation asschema.api_name_invalidorschema.api_name_duplicate) make each committed execution write oneoperation.<name>outbox event in the transaction of its Revision, invocation record and audit entry, so the event exists exactly when the execution committed; dry runs, refusals, executions that need review (agenttaskwrites that open a Proposal included) and idempotent replays write none. The payload names the execution, carries the parameters as recorded (possibly sensitive ones***) and up tokeeper.operations.side_effect_max_edits(100) edits. Webhooks subscribe withoperation.<name>oroperation.*; a webhook created withouteventsnow getsrevision.committedandoperation.*. Sensitive values in events reach a webhook only when it was created with matchingclearances(pii,confidential,restricted; creating one needs that clearance permission on the Domain), others getwebhooks.masked_value; vault values are stored as references and opened when the delivery is sent, so an erased one arrives aswebhooks.erased_value. Every attempt of an event carriesIdempotency-Key: evt_<sequence>(webhooks.idempotency_header): delivery is at least once and unordered. Each consumer of the outbox now keeps its own delivery state (outbox_pending, filled by a trigger in the writing transaction), so a second consumer no longer loses events to the webhook worker, and an event whose transaction commits late is not skipped. Outbox events are pruned once every consumer handed them off and they are older than the newstore.outbox.retention_s(7 days;webhooks.retention_snow applies to deliveries only), or afterstore.outbox.max_age_s(30 days) in any case,store.outbox.prune_batch(10,000) at a time. New metricslorebase.outbox.handoffs,lorebase.outbox.prunedandlorebase.operations.events. Migration0020addsoutbox_consumers,outbox_pendingandwebhooks.clearances, moves undelivered events to thewebhooksbacklog and dropsoutbox.delivered_atandoutbox.attempts. - Rate limits shared by every Keeper:
keeper.limits.rate.backend: postgreskeeps the token buckets in the application database (the UNLOGGED tablerate_limit_buckets), so several Keepers enforce one limit instead of each its own. A Keeper leases a few tokens per query (postgres.lease_tokens, at most a quarter of a bucket) and serves requests from them, so most requests cost no query and the Keepers together never exceed a limit. Failed-authentication buckets are written on every failure and fail closed (429) when the database does not answer withinpostgres.timeout_s; the other buckets followpostgres.on_error(local, the default, enforces them in the process meanwhile;allow;deny). The default backend is now calledlocal(memorystill works). The Helm chart setspostgreswhenkeeper.replicaCountis above 1 or the HPA is on (keeper.rateLimitBackendoverrides it); compose readsKEEPER_RATE_LIMIT_BACKEND, to set with thescale-outprofile. New metriclorebase.ratelimit.shared.queries(operations). - Auditing of reads (
keeper.auth.audit.reads, off by default). Successful reads of authenticated callers are recorded inaudit_read_log, a table with hash chains of its own (per Domain and installation-wide) so that sampled, droppable read records never touch the chains of actions.sample_rate,principal_typesandkindschoose which reads are kept, except that a read that showed or masked a sensitive value (pii,confidential,restricted, vault values included) is always recorded, withsensitive: [shown|masked]. Records are queued and written in batches off the request path; a full queue or a failed batch drops records, counted inlorebase.audit.reads.dropped.python -m lorebase_keeper audit verify --readsverifies the read stream and the dailyaudit_verifyjob checks both streams (operations). - Signed checkpoints of the audit chains outside the database (
scribe.audit_checkpoints, off by default). Anaudit_checkpointjob, everyinterval_s(one hour), signs the newest entry of every chain with an Ed25519 key and stores it in a directory (never replacing a file), at an HTTP endpoint or in an S3-compatible bucket (PUT signed with Signature Version 4), with the address checks and pinning of webhook targets.python -m lorebase_keeper audit verify --checkpoints PATH --checkpoint-key PUBreports checkpointed entries that are missing or changed, so a table owner who cuts the end of a chain or recomputes one is detected. New metriclorebase.audit.checkpoints(operations). - The five-day proof scenario runs in CI. The End-to-end workflow builds the backend image from the checkout (Docker layers cached), starts the demo stack with the deterministic fake inference provider and runs
examples/five_day_demo/run.shon pushes tomain, nightly, on demand and on pull requests that changeexamples/,packages/,deploy/compose/ordeploy/docker/; on failure it uploads the stack logs and the run transcript. Every day of the demo now ends with assertions (demo.py assert-*: schema applied, import counts, merge with resolved Conflicts, Source runs and webhook, agent writes through Proposals and the audit log, restore equals live), a failed one exits non-zero, and the run prints the seconds each day took. Day 2 gained a document: a contract extracted with the deterministic tiers into a Proposal of items that is reviewed and merged, and the agent of day 5 now also opens a Proposal of its own withtaskandintent(its key gets theeditorrole onagent/*).DEMO_NO_BUILD=1makesstack.sh upuse a prebuilt image (demo). - Personal data vault and erasure (design 13.5). An Attribute tagged
piiwithvault: truestores its values encrypted (AES-256-GCM) in thevaulttable, and Record objects hold a token{"$vault": "vlt_..."}instead; hashes, diffs and merges compare tokens, which stay the same for the same value of the same Attribute of the same Record.POST /api/v1/domains/{domain}/vault/erase(new permissionpii.erase,adminonly among the built-in roles),lorebase admin erase KIND --key Kor--attribute A --value V, andvault.erase_record/erase_valuein the Python client erase a data subject's values on every branch and Revision, in one transaction with an audit entry that never holds the value; erasing again changes nothing, and reads showkeeper.reads.sensitivity.erased_value([erased]). Keys come from the newvaultsettings (token_key,encryption_key,key_id,previous_keys), the same for every process that commits; without them, writes of vault values are refused (value.vault_unavailable). Vault Attributes cannot be unique, indexed, derived, references, titles, primary keys or foreign keys, and vault storage cannot be turned on or off on an existing Attribute (schema.vault_invalid). References are accepted in writes only for the Record they belong to, and Conflicts over vault values are resolved by picking a side. Bundles carry tokens, never vault values, and their import refuses plain values in vault Attributes.lorebase admin erase --value -reads the value from standard input. Migration0019adds the columns that bind a vault row to its Record. The Modeler keepsvaultwhen it edits a Kind. - Sensitivity masking on reads (design 10.2). Values of Attributes tagged
pii,confidentialorrestrictedare shown only to callers holding the new permissionspii.read,confidential.readorrestricted.read(Domain-wide, on the branch read, or narrowed to the Kind;adminholds them, custom roles grant them); other callers getkeeper.reads.sensitivity.masked_value(***), or nothing withomit: true. This covers Records, Links, query includes, traversals, neighborhoods, history and lineage values, diffs and changesets, explain and context packs, Conflicts and stored resolutions, and Operation edits, so the CLI, the Python client and the MCP server see the same. Filtering, ordering or grouping by a sensitive Attribute needs the clearance (403sensitive_attribute), and by a vault Attribute is refused (422vault_attribute). Responses are cached and tagged per clearance. Clearances come from Domain-wide grants or grants narrowed to the Kind (by its id on the default branch), not from grants narrowed to a branch; the tags of the default branch, carried through Derivations, apply to every Revision and branch read; removing a tag on any branch, by a schema change or a merge, needs its clearance (403sensitivity_lowered), and writers without a clearance check (agent auto-merge, Source merges) cannot. Derived Attributes and the step values of Operations must carry the tags of what they read, preconditions cannot read sensitive Attributes (schema.sensitivity_inherited), sensitive Attributes cannot be primary or foreign keys, and uniqueness violations on them no longer name the other Record. Extraction items are masked too, Operation invocation records and Proposal descriptions no longer keep parameter values that may be sensitive, and Source run errors are shown only to callers cleared for every tag of the schema. - Copies and deletions of large branches run in the background, so they no longer hit
database.statement_timeout_ms(at 1M Records a copy took 85 to 95 s and failed through the API). A copy branch whose source holds more thanstore.materialize.async_copy_min_rowsRecords and Links (10,000) is created at once as an overlay of its source, readable and writable right away, and astore.materializejob copies its rows in batches ofjob_batch_rows, one transaction each, resuming from its cursor after a failure, then switches the branch to a full copy. Commits on the branch and on its source stay correct during the copy and wait for at most one batch; a schema change on the branch is refused with 409branch-materializingandRetry-Afteruntil it is done. Deleting a branch with more thanasync_delete_min_rowsrows hides it at once and astore.branch_deletejob deletes its rows in batches. At mostjob_concurrencyof these jobs run at once across workers. Branch responses havematerialization_status(pending,running,done,failed) andmaterialization_progress;lorebase branch create --mode copy --waitwaits for the copy, andlorebase branch list|getshow it. Overlays promoted because their base moved too far are copied the same way. New metricslorebase.store.branch_jobs.rows,.runsand.batch.duration. Migration0017addsrefs.deleted_atand thebranch_materializationstable (operations, store schema notes). - The embedding model of a Domain is advertised to agents:
GET /api/v1/domains/{domain}/schemahas asearchblock with the searchmodesand, when a Kind hassearch.embedding_template,embedding(model,dims) read fromsearch.semantic.modelandstore.embeddings.dims;lorebase_schemashows it as aSEARCHline, and the Python client hasSchemaDocument.search. inference.embeddings.query_prefixanddocument_prefix(empty by default) for models trained with prefixes, such as E5:lorebase-inference embed --query TEXTputs the query prefix before the text, and Scribe puts the document prefix before every embedded Record text. Changing the document prefix makes the next pass embed every text again.lorebase-inference embed TEXTwithout--queryno longer adds anything, which is what it did before prefixes existed (inference providers).- Garbage collection deletes the embeddings no branch head references (for example the vectors of Records that only a deleted branch held);
python -m lorebase_store.gc --dry-runand thegcjob report their count asembeddings(operations). - The Modeler edits
search.embedding_templateandsearch.embedding_sensitiveon the Kind form, checks the placeholders against the Kind's Attributes (unknown, derived and sensitive ones) before a change is sent, and follows an Attribute rename in the template. - Embedding jobs for semantic and hybrid search. A Kind opts in with
search.embedding_templatein the schema ({attribute}placeholders by api name, plus{_kind}and{_key}; sensitive Attributes only withsearch.embedding_sensitive: true; invalid templates are reported asschema.embedding_template_invalid). Scribe's scheduler queues anembeddingsjob per Domain everyscribe.scheduler.embeddings_interval_s(60 s): it embeds, branch by branch, the Records that lack a vector of the configuredinference.embeddingsmodel, re-renders the Kinds whose template changed, skips texts whose hash did not change and copies the vector of an identical text instead of calling the model. Vectors stay keyed by content hash, so overlay and copied branches only add work for the Records they change. Batching, a row budget per job with resumable continuations, call pacing and retries come from the newscribe.embeddingssettings; the model's dimension is checked againststore.embeddings.dimsbefore the first call.lorebase-scribe embeddings DOMAIN [--branch B] [--reindex] [--prune]backfills, reindexes and, after a model switch, deletes the vectors of other models. New metricslorebase.scribe.embeddings.recordsandlorebase.scribe.embeddings.call.duration. Migration0014addsembeddings.text_hashand theembedding_cursorstable (scribe). - Query vectors on the caller's side, since Keeper and Herald never call a model:
lorebase-inference embed TEXTprints the vector of the configured embedding model, andlorebase search TEXT --vector-file FILE(-for standard input) sends it for hybrid search (inference providers). - Per-Record history and lineage:
GET /api/v1/domains/{domain}/kinds/{kind}/records/{key}/historylists the Revisions that changed a Record on a branch, newest first, with the changed Attributes, their provenance and, withvalues=true, the values before and after;GET .../records/{key}/lineagetells per Attribute where the current value came from (provenance, Revision, Source, Operation, extraction evidence, Proposal). Both read one index range of the branch history, are authorized and cached like Record reads, and are bounded by the newread.historysettings. Also in the Python client (records.history,records.lineage), the CLI (lorebase record history|lineage), the MCP server (lorebase_history,lorebase_lineage) and the History tab of the Explorer Record page. - Merge policies
authorityandnewest_wins(design 6.10).authority(globally or per Attribute inauthority_attributes) is a ranked list of origins such assource:<id>,principal:<id>,actor:<id>ororigin:extraction: on an attribute value Conflict the side whose last change comes from the higher ranked origin wins, andschema_authority: trueranks an Attribute's schemaauthoritySource first.newest_wins(ornewest_wins_attributes) takes the side whose last change has the later provenance time, equal times going to the greater provenance id. Both read the Lineage of both branches, leave the Conflict to a person when they cannot decide, and record why they decided in the attempt'sauto_resolutions[].details, which the Review screen shows. Set them in the Domain'smerge_policysetting; Source merges apply them too. - Sign-in for the web app with Keeper as backend-for-frontend (
keeper.auth.sessions, off by default; needs anoidcauth mode).GET /api/v1/auth/loginruns the OpenID Connect authorization code flow with PKCE against the configured provider,GET /auth/callbackchecks state, nonce and the ID token and sets an opaqueHttpOnly,SameSite=Laxsession cookie (__Host-prefixed andSecureunlesscookie_secure: false),GET /auth/sessionreturns the user and the session's CSRF token, andPOST /auth/logoutends the session and returns the provider's end-session URL. Sessions live in the newweb_sessionstable (migration0013; only a SHA-256 of the session id is stored, provider tokens are encrypted withsessions.encryption_key) with idle and absolute timeouts, a new id on every sign-in and refresh-token checks at the provider. A session maps to the same principal, groups and grants as the user's bearer tokens; POST, PUT, PATCH and DELETE with the cookie need the CSRF header, a trustedOriginand no cross-siteSec-Fetch-Site. API keys, bearer tokens andauth.mode: nonework as before. The web app getsVITE_AUTH_MODE=session(authModein/config.js): a sign-in screen at/login, a sign-out button, cookie requests with the CSRF header, and a redirect to sign-in on 401 (deployment). - Operation, Derivation and Source editors in the web app Modeler (tour). Operations: typed parameters, preconditions, steps with their Kind or Relation and values, review policy, permissions, side effects, rate limit, and a dry run of the saved Operation on the Branch. Derivations:
expression,aggregateandpathvariants, with the computed Attribute picked from the schema. Sources: the fields of each type (records_path,flatten_separator,httpheaders and pagination forhttp_json),secret://references only for connections and credential headers, key, Attribute and Relation mapping, sync mode with watermark, schedule and merge policy. Expressions are edited in a CEL editor, and the findings of Keeper's dry run, the CEL type check included whenschema.expressions_enabledis on, appear next to the field. Edits go through the same change set, Branch and Proposal flow as the other editors, and keys a form does not edit stay as the YAML has them. - Helm chart values for the web sign-in:
config.keeper.auth.sessionssettings, the OIDC client secret and the session encryption key read from the Secret (secrets.keys.oidcClientSecret,secrets.keys.sessionEncryptionKey, orsecrets.values.*for a chart-managed one) and wired assecret://envreferences,web.config.authMode: session, render-time checks, and theci/sessions-values.yamloverlay (Kubernetes). - Audit chain verification (operations).
python -m lorebase_keeper audit verify DOMAIN|--installation|--allrecomputes the hash chains of the audit log with the maintenance DSN, prints the first entry that does not verify (or JSON with--json) and exits 1 when there is one;--after-seqresumes from thelast_seqof an earlier run.GET /api/v1/domains/{domain}/audit/verify(permissionaudit.read) andlorebase audit verify(exit code 2 on a broken chain) check one Domain's chain, and the Python client hasaudit.verify. Entries are read in batches, so memory does not grow with the log. Scribe queues anaudit_verifyjob of every chain eachscribe.scheduler.audit_verify_interval_s(one day; 0 disables it), counted in the new metriclorebase.audit.verificationsby outcome, and theLorebaseAuditChainBrokenalert fires when a chain is broken. - A threat model: STRIDE per trust boundary (web sign-in, API keys and OIDC, authorization in Keeper, Scribe connectors and document intake, Herald, Postgres roles and row-level security, webhooks, inference providers, the release pipeline), each mitigation with the file that implements it, and the residual risks in order of priority.
- An API stability policy for
/api/v1: what is stable, which changes are compatible or breaking, the deprecation process withDeprecationandSunsetheaders (none is deprecated yet, so Keeper sends neither), client compatibility, MCP tool stability and thelorebase.schema/v1format. It binds from 1.0; before it, breaking changes are named in the changelog. - Webhook clearances can be changed:
PATCH /domains/{domain}/webhooks/{id}with{"clearances": [...]}replaces them, audited aswebhook.updatewith the clearances before and after. Each clearance added needs its clearance permission on the whole Domain, as at creation; removing one needs onlydomain.settings. The Python clients havewebhooks.set_clearances(agents). - Storage profiles (performance):
store.storage.profile(compact,balanced,fast; composeLOREBASE_STORAGE_PROFILE, Helmconfig.store.storage.profileandci/storage-<profile>-values.yamlwith matching Postgres settings) decides what the database keeps besides the rows: shared or inline lineage maps, the GIN index on Record values, the per-Attribute indexes ofindexedAttributes, the trigram index of the typo lexicon, half or full precision for the HNSW index, TOAST compression and the fill factor ofmat_records; every other key ofstore.storageoverrides one choice. Every feature works in every profile.python -m lorebase_store.storage status|apply|prewarmshows, applies (batched, resumable, reads go on;--compact --reindexmoves rows into free space and gives pages back) and prewarms (pg_prewarm, autoprewarm withshared_preload_libraries). The sizing guide gives the memory that keeps the agent read set resident at 1M, 10M and 100M Records per profile. At 1M Records the hot set of the agent read path went from 12.5 GB to 5.5 GB (balanced, afterapply --compact --reindex); at 10M Records the hot set is estimated at 49 GB against 57 GB measured before (the 10M after-run is pending). - Shared lineage maps (migration
0036, store schema notes):mat_records.lineage,lineage_revisionsandmat_links.lineagehold a reference to a map stored once per Domain (mat_lineage_maps) instead of the map; readers resolve it withlb_lineage. Record rows are about 55% smaller.tests/perf/working_set.pymeasures relation sizes, pages per read and buffer residency;tests/perf/halfvec_recall.pythe recall of a half-precision HNSW index. - Example questions in the schema:
domain.examplesoflorebase.schema/v1lists optional{label, question}pairs (at most 20, unique labels; design 5.13). They are part of the Domain element, so changing them is a schema change, andGET /domains/{d}/schemareturns them in itsdomainblock. The web app offers them in Explore and the Retrieval lab only when present. Schemas without them load and hash as before.examples/supply/schema.yamldeclares the five questions of the example agent. VITE_DOCS_URL(docsUrlin/config.js): the documentation site behind the web app's "Learn more" links (defaulthttps://aantenore.github.io/lorebase,nonehides them).LOREBASE_GUIDE_PROXYmakespnpm devandpnpm previewproxy/guideto a guide service.pnpm screenshots --live --keeper <url> --domain <id> --guide <url>takes the tour pictures from a running stack read only: the existing Domain as it is, a real Explore answer and its trace (apps/web).
Changed¶
.env.exampleno longer setsGUIDE_ALLOW_ANONYMOUS=false(the compose default is still false); withmake up PROFILES=assistantit defaults to true. A value set in an existing.envis kept. TheUV_SYNC_ARGScomment no longer lists fastembed, which thelorebase-mlimage carries.- Agent trace:
trace_allno longer traces the web app's own requests (agent trace). The web app sendsX-Lorebase-Client: webon every Keeper request; Keeper leaves out oftrace_allthe requests carrying it (keeper.trace.client_header,web_client) and those authenticated by a browser session cookie that name no session.keeper.trace.include_web: truetraces them again. Guide and Herald sessions, header opt-ins and listed principals or keys are traced as before. The header is added to the CORS allowed headers when tracing is enabled. - Agent trace: values compared by filters are kept on Attributes that are not sensitive (agent trace), so a timeline shows
single_sourced = trueinstead of{"redacted": "boolean"}. The newkeeper.trace.capture_filter_values(non_sensitiveby default, ornone) decides; a field is kept only when the read's own field check finds it not sensitive under the effective tags used for masking (through Traits and Derivations;titleandkeyby their title and primary key Attributes). Kept values are scalars or lists of scalars, cut tomax_text_charsand the newmax_filter_value_items(20); they are shown like the rest of the query, to the caller and to readers cleared in the Domain only. Setcapture_filter_values: nonefor the earlier behavior. - Snapshot builds for reads at a past Revision are bounded: one build per Domain at a time,
read.time_travel.snapshot_max_concurrent_buildsper process withsnapshot_build_wait_msof waiting (503 withRetry-After),snapshot_builds_per_minuteandsnapshot_build_burstper principal (429),snapshot_max_history_revisionsof history (422), and asnapshot_min_age_sgrace period before a snapshot may be dropped for another; requests are validated before a build, and snapshots use the sensitivity tags of their Revision's schema. - Large branch copies never run in one transaction any more (store schema notes). A copy above
store.materialize.async_copy_min_rowsthat cannot start as an overlay (a copy of an overlay, from an older Revision, or with the overlay cap reached) is createdbuilding(migration0031) and astore.materializejob writes its rows from the tree of its start Revision in batches; meanwhile its head is read like a past Revision and commits into it answer 409branch-materializing. Overlays over a base above that size are no longer promoted inline: their limits queue the batched copy, and a rebuild of the base detaches them and builds them from their trees. - Schema changes on an overlay whose background copy is queued or running are accepted instead of answering 409
branch-materializing: the change applies to the rows the commit writes and the copy job reconciles derived values, path Links, titles and search vectors in batches after its switch. A schema change on an overlay over a large base queues that copy instead of promoting the overlay inline. - Background copy and deletion batches size themselves by the time they hold the branch lock: from
store.materialize.job_batch_min_rows(100) up tojob_batch_rows, aiming atjob_batch_target_ms(200 ms) and atjob_batch_contended_ms(50 ms) once a commit waited for the lock, so a commit on a branch being copied no longer waits up to 0.5 s for a batch. New metriclorebase.store.branch_jobs.batch.rows. - Schema suggestions no longer keep evidence or samples that hold a vaulted value of any vault Attribute of the Domain (extraction). Extraction compares keyed fingerprints of the quote's word sequences with the live vault rows and opens nothing. It fails closed: a quote past
discovery.vault_match_max_text_words,vault_match_max_text_charsorvault_match_max_candidates, or any quote of a Domain with vaulted values when Scribe has no vault keys, loses its evidence. A suggestion without evidence looks like one that never had any; the job report no longer carriessuggestion_evidence_withheld, and operators get the counterlorebase.extraction.evidence_withheld. - mypy no longer carries a project-wide override for
pyarrow(reported as an unused section when only some packages are checked): the three optional pyarrow imports ignore its missing type information where they are. - Sensitive Attributes are left out of full-text search for every caller: their words are no longer in search vectors, the lexicon, corrections or snippets (a
searchablesensitive Attribute gets the warningschema.sensitive_attribute), and a Kind whose title Attribute is sensitive has Records without a title. Semantic and hybrid search use the vectors of a Kind that embeds sensitive Attributes only for callers cleared to read them. Rows materialized before keep their titles and search vectors until the Kind's definition changes on that branch (any edit of the Kind, such as its description, recomputes them);materialize.verifyreports such rows until then. - Validation messages of schema migrations no longer quote values of sensitive Attributes.
- Deleting a branch above
store.materialize.async_delete_min_rowsrows returns 204 at once while its rows are deleted in the background; until then its name stays taken, and creating a branch with it answers 409already-existswithRetry-After. Copies aboveasync_copy_min_rowsanswer withmaterialization: overlayuntil their copy is done. Set both thresholds very high to keep copies and deletions in the request transaction. - Typo-tolerant search matches words instead of whole titles. Each branch keeps, per Kind, the distinct words of its search vectors (
mat_lexicon, kept by triggers onmat_recordsin the writing transaction; an overlay adds its base's), and a query word the searched Kinds do not hold is corrected to their closest words by trigram similarity (negated words are left alone), then searched as(word | correction ...). Hits of the query as written rank first, the others after them atsearch.trigram_weighttimes their rank.POST /searchresponses havecorrections(word,replacements; empty when nothing was corrected), also in the Python client (SearchResult.corrections) and inlorebase_search.search.trigram_thresholdis now the least similarity of a correction (default unchanged, 0.3), andsearch.typo_candidates(3; 0 turns it off) andsearch.typo_min_length(4) are new. Words are lexemes of the text search configuration, so a stemming configuration reports stems. Migration0018adds the table, its triggers and backfill (writes tomat_recordswait while it reads every search vector), the read functionslb_search_correctionsandlb_search_corrected(grant them to an application role created later withgrant_read_functions, as before), and drops the title trigram index of0011. At 1M Records typo-tolerant search takes 7.6 to 14 ms at p95 instead of 116 to 123 ms; the triggers add about 5% to a commit of 1,000 changes at 1M Records and about 12% at 100k (performance, design 8.4). - The performance harness (
tests/perf/bench.py) reports p99 next to p50 and p95, times Record history and lineage, and with--seed-database NAMEkeeps the loaded dataset in its own database and measures a copy of it, so a run at 1M Records loads once. The 1M-Record run is in performance: every read is within budget except typo-tolerant title search (about 120 ms at p95 against 50 ms), the 2-hop neighborhood on branches other thanmain(29 to 34 ms against 30 ms) and lineage against the 10 ms proposed for it; creating or deleting a copy branch at that size takes longer than the defaultdatabase.statement_timeout_ms. - The web app Canvas lays large graphs out in a web worker instead of on the main thread: the graph is drawn at once and refined while it is on screen, the layout stops when it settles or at a time budget and can be paused, and large graphs use Barnes-Hut, hide edge labels and most node labels, and hide edges while panning. On a generated graph of 5,000 nodes and 10,000 edges the first render takes about 0.6 s instead of 1.7 s (performance). The limits are configurable (
VITE_CANVAS_*), andpnpm perf:canvaswithVITE_MOCK_PERF_NODESmeasures a generated graph. - Herald gives each Keeper request its own span: a request hook on the Keeper client sends a
traceparentwith a fresh span id whose parent is the span of the MCP tool call, in the caller's trace, so the requests of one tool call are told apart in the agent trace and in tracing backends. When an OpenTelemetry span is active in Herald, each request is a child of that span instead; withherald.trace.enabledoff notraceparentis sent. The Python clients takeevent_hooks, passed to httpx (agent trace). - Operation executions that need review now emit their
operation.*events when their Proposal merges (agents). An agenttaskwrite keeps its events inoperation_deferred_events(migration0028); the merge of a Proposal from the task branch writes those whose Revision the merged source head contains to the outbox and deletes them, in the merge transaction, so each is written once. They name the target asbranch, the mergedhead,source_branchandproposal, and are counted inlorebase.operations.eventswhen the merge commits. Events of a task branch deleted unmerged are dropped, and so are those older thanstore.outbox.max_age_s. - The web app is domain-agnostic and its copy is shorter. No screen names the Kinds, Records or questions of a dataset (the
supplypresets, placeholders such as a question about bearings in Torino, and example keys are gone; mock data stays in the mock fixtures), and no description puts the Domain's name into its text. Every user-facing string lives inapps/web/src/messages: a heading and at most one short line per screen, with "Learn more" links into the docs, consistent terms and no internal jargon.src/test/domain-agnostic.test.tsfails on dataset identifiers or an interpolated Domain name outside the mock fixtures and tests. The "planned screen" placeholders (Explorer, Modeler, Review, Sources, Settings, the Record page's Run Operation and the Canvas diff overlay tab) are removed. - Web app layout: shared primitives (
PageHeader,Section,Card,CardGrid,FormRow,FormAction) give every screen the same spacing, equal-height cards and buttons on the line of their inputs; the navigation becomes a scrolling row below 768px, tab lists scroll instead of overflowing, and tables, long keys and JSON no longer push screens wider than a phone. The Explore panel is no longer repeated in the Retrieval lab: the lab links to Explore ("Ask the guide") when a guide service is configured, and its parameter fields have readable labels. - The tour pictures, the README gallery and
docs/tour.mdshow the current app on a demo stack with real data, a real Explore answer and its Agent activity trace, Record history, the Derivation preview, typo-tolerant and hybrid search, extraction items and schema suggestions; four pictures come from the mock and say so.
Fixed¶
run_index_jobs(lorebase_store.indexes) sends job heartbeats while a build runs, so a long index build is no longer reclaimed as lost afterstore.jobs.stale_after_seconds. Releasing a job on SIGTERM and reclaiming one after SIGKILL were checked in containers (validation).- Jobs of a Scribe that is killed or loses the database no longer stay
runningfor an hour: a job whose worker sent no heartbeat forstore.jobs.stale_after_seconds, now 90 seconds (was 3600, measured from the last heartbeat; Scribe sends one everyscribe.worker.heartbeat_interval_s, now 30 seconds, was 60), is reclaimed by the scheduler leader at its next tick: queued again, or failed once it usedstore.jobs.max_attempts, so a job that keeps killing its worker is not run forever (scribe). A worker that lost a job this way no longer overwrites the outcome of the run that replaced it (its run counts aslostinlorebase.scribe.jobs), andpython -m lorebase_store.jobs worknow sends heartbeats too. - A stopping Scribe (SIGTERM) leaves no job
runningeven when a handler does not stop when cancelled:shutdown_cancel_s(scribe.worker, 5 seconds) aftershutdown_grace_sits jobs are put back in the queue anyway, without counting the attempt. - Agent trace: calls without a session id of one client no longer split into many one-call inferred sessions when they run concurrently (agent trace). A step carries the time its request started but is written when the request finishes, and the grouping only looked back from each step, in write order: a call that started before the last one written found no session and opened its own (the web app's parallel reads opened a new session every few milliseconds). Inferred steps of a batch are now grouped in time order and join the session of the nearest step of their client within
inferred_session_idle_son either side. - Guide in the compose stack and the Helm chart has a writable state directory (
/var/lib/lorebase/guide, volumeguide-data; Helmguide.persistence, an emptyDir by default) while its root file system stays read-only, so it remembers structured output modes across restarts and no longer logs a warning at each start. The example agent reports the keys of the edits and the Proposal ids from the response to the committing call, not those of its dry run. - Hybrid search ranks its results by fused score again: Records found only by the semantic list were listed before every lexical match.
- Guide's
querytool told the model awhereandorder_byshape Keeper's query DSL refuses ({attr: value},{attr: asc|desc}), so in live runs most failed tool calls were refused queries and many explorations ran out of steps. The description now gives the DSL's predicate forms and a valid example (checked against the DSL in tests), and thehistory,neighborhoodandtraversedescriptions state their bounds (validation). - A Guide exploration whose token budget ran out after several useful reads answered
Not found in the graph.: only the step budget left a turn to answer. A tool step is now taken only when the budget still covers it, the next call with a full tool result and an answer; otherwise that turn is the final one (guide). The input estimate takes its characters-per-token ratio from the endpoint's count of the previous call (JSON tool results ran at about 2.2 on Claude models, not 4), and the answer of the final turn is kept when the endpoint counted more than estimated. The instructions now ask for a partial answer with citations rather thanfound: falsewhen part of the question is answered, and treat a checked negative result (no Record meets the conditions) as an answer. The tokens of a model answer that does not parse now count towards the exploration's total, and themodel call failedlog line names the schema problems (paths and keywords, never answer text). - Guide's
neighborhoodandtraversetools dropped the Link Attributes Keeper returned (a stockedquantity, alead_time_days), so the model answered that the graph held no quantities. Edges now carry their values. - The example process agent sent
temperature: 0on every request, which current Claude models refuse with HTTP 400.--temperature none(AGENT_TEMPERATURE) leaves it out and--extra-body(AGENT_EXTRA_BODY) passes options such asthinking(process agent). - Herald no longer logs every Keeper request URL, Record keys included, at INFO through the HTTP client's logger; over stdio those lines went to the agent's standard error.
- Record lineage stays bounded on Records with long histories: changes are counted up to
read.history.lineage_count_max(10000;changes_truncatedreports more), and rows without lineage revisions search only the newestread.history.lineage_scan_changeschanges (1000). The newstore.lineage_backfilljob fillslineage_revisionsfor rows materialized before migration0032. The creating change falls back to the history when the Revision stored for it has no history row, maps that are not JSON objects read as empty, and a merge attributes only the values it changes, so values it leaves keep the Revision that set them (agents). - Record lineage after a fast-forward names the Revisions of the source branch that set each value, as a rebuild does, instead of the source head; rebuilds and backfills attribute a merge only the values it changed, and
python -m lorebase_store.jobs lineage-backfill --recompute(job payload"recompute": true) corrects rows written by merges before that fix. Workers starting together queue the lineage backfill once. - After a fast-forward, the branch history holds one row per change of each fast-forwarded Revision, as a rebuild writes it, so Record history and the lineage change count match a rebuild (
store.materialize.fast_forward_history_max_revisions, 1000; a longer range keeps one row per element at the source head).lineage-backfill --recomputerewrites the history, Records and Link lineage of earlier fast-forwards the same way. - A background copy that keeps failing no longer holds up the deletion of its base for good: each failed job is retried after a delay doubling from
job_retry_seconds(at mostcopy_retry_max_seconds), and aftercopy_max_failuresfailures the overlay is detached from its base and built from its tree instead. New outcomesretried,fallbackandgiven_upoflorebase.store.branch_jobs.runsand alertsLorebaseBranchCopyFallbackandLorebaseBranchBuildFailed(operations). - Derivation and Source previews (tour). A previewed value computed from sensitive Attributes of another Kind is masked unless the caller is cleared on the Derivation's Kind and on every Kind the values come from, and a masked item reports a failure without its error code (
error.codeis now nullable).keysare bounded byread.derivation_preview_max_key_length(1024).POST /sources:previewaccepts aschema.readgrant narrowed to the Source's target Kind and answers 403 for other Kind-narrowed callers before validating the change set; it refuses rows of more thanread.source_preview_max_columnscolumns (200) and requests overread.source_preview_max_cellsevaluations (1000, rows times expression mappings) with 422, and evaluates off the event loop. Expressions over rows are compiled once over every column of the Source, so a column a row lacks is null in Scribe and in the preview alike (it was an unknown-variable error), and very large decimals keep their exponent form. - Path Derivations: values over the inverse direction stay current when a Link pushes another far key out of an intermediate Record's
store.traverse.path_fanoutbound. Job workers rewrite branches computed with another fan-out in the background next to their job loop instead of before it, and a branch that fails or is being copied in the background no longer stops or blocks the sweep. Operationlinks()over a path reads its first step no further thanstore.reads.max_page_rows, and neighborhoods following a composed path reporttruncatedonly when Links were left out. - Embedding vectors are keyed by the fingerprint of the embedding template as well as by content hash and model (migration
0024). Branches whose templates differ for the same content keep a vector each instead of overwriting one another, and Keeper's semantic and hybrid search read the vectors of the searched branch's template. Vectors of identical texts are still copied without a model call, now across templates too. The upgrade sets the fingerprint wherever every branch holding the content agrees on one template, and the next pass re-renders each text once to fill in the rest. Garbage collection also deletes vectors of templates no branch uses any more. Scribe resolves embedding templates once per schema hash and keeps the lastscribe.embeddings.template_cache_sizeschemas. - Database sources of schema inference are bounded further: at most
store.profiling.sql.max_tablestables (now 50),total_timeout_sfor a whole request, andmax_value_charsper value, with unbounded text columns cut and binary or unknown-type columns not read. Aschemamust be a plain name found in the catalog, andkeeper.schema_inference.allowed_schemascan restrict a source to some of them. A failed or misconfigured source answers a generic 503. Previews cut long text (preview_max_cell_chars). LorebaseGcNotRunno longer fires for a gc schedule set to dry runs (scribe.scheduler.gc_dry_run: true). Scribe counts the collections it schedules inlorebase_gc_scheduled_total{dry_run}, and the alert needs real scheduled collections (dry_run="false") that never complete within 3 days. The Scribe dashboard shows scheduled runs next to completed ones.lorebase_jobs_queue_depthreports jobs waiting for a retry delay (queued with arun_afterstill ahead) asstatus="retrying", soqueuedcounts only claimable jobs andLorebaseJobQueueStalledno longer counts jobs no worker could claim yet.- LinkML: path Derivations are exported as slots carrying their steps, inverse name and
materializedas annotations, andschema import --applyreads them back, so a round trip keeps them. Applying to a Domain no longer warns that Operations, Derivations and Sources are not imported when the merge keeps them, and--prunewithout--applyor--dry-runis refused instead of doing nothing. - Large commits no longer slow down with the number of Links they write. Looking existing Links up by
(from, to)pairs planned one index scan per pair (about half a millisecond of planning per pair, so 0.5 s for a chunk of 1,000) and now runs as one semi-join on two arrays (13 ms for the same chunk); cardinality and required-Link checks count the batch's Links per key once instead of once per key; canonical JSON writes strings without characters to escape in one step. Loading 28,000 Records and 84,000 Links in 15 commits went from 67 to 48 s on the same machine. - Commits no longer slow down with the size of the Kinds they write. The search vectors of a commit's Records were refreshed by filtering every Record of the Kind on the commit's Revision (7 s per statement for a Kind of 200,000 Records) and are now refreshed by key; unique values were checked with an expression the Attribute's unique index does not match (a minute per thousand values at 160,000 Suppliers) and now go through the index.
- The background copy of a branch no longer fails at 10M Records before copying anything: its first step counted the base branch's rows for the progress report (85 million rows, past the statement timeout).
rows_totalis now the planner's estimate until the copy switches, then the rows read. - Schema suggestions no longer list one fact twice under two names: Attribute suggestions of the same Kind whose names share at least
schema.suggestions.merge_similarityof their words (0.6) and whose samples share a value type are folded into the better supported one (renewal_notice_daysandrenewal_proposal_notice_days,termination_notice_daysandtermination_for_cause_notice_days). Kinds named by a generic document or workflow word (schema.suggestions.kind_stoplist) or by an Attribute (Buyerbesidebuyer) are not suggested, and a proposed Kind is listed once it haskind_min_attributes(2) Attribute suggestions or as many observations. - Tier 2 extraction asks for link Attributes: a Relation with link Attributes has its own branch in the answer schema with the link's
values(evidence for each, the required ones required), and the prompt asks for the required ones. Link values are verified and grounded like record values and reach the Link item; before,stocked_atLinks came withoutquantity. A Link item still missing a required link Attribute stays pending, with the names inmatch.missing. - A structured output mode an endpoint refuses is remembered for the process, per
base_url, model and output settings, instead of being tried again at the start of every extraction job (two refused requests per job with a strict-only endpoint before). - A tier 2 chunk whose answer stays invalid after the repair attempts no longer fails the whole extraction job: the chunk is skipped with a warning, counted as
model_invalid_answers, and its tokens still count toward the job budget and usage. An answer cut off before any output says that a reasoning model may have spent the token limit on reasoning. - Tier 2 timestamp values written without an offset (
2024-01-23,2024-01-23T10:15) are read as UTC instead of being discarded as invalid, and timestamps are grounded by their day in the quote ("approved on 23 January 2024"). - A webhook receiver that disappears no longer holds a Scribe worker. Each delivery attempt, host lookup and every checked address included, is now cut off after
webhooks.timeout_s; before, the budget applied to each address in turn, the lookup had none, and one pass sent up towebhooks.batchdeliveries one after the other, so a receiver that accepted connections and never answered, or dropped packets, kept awebhook_deliveryjob running for many minutes while its heartbeat kept it from being requeued. Connections give up after the newwebhooks.connect_timeout_s(5 s) and try the next checked address; a pass sendswebhooks.concurrency(8) deliveries at a time, starts none afterwebhooks.pass_timeout_s(60 s) and releases the rest to the next pass without counting an attempt; a pass still running afterwebhooks.lease_sfails its job attempt.webhooks.lease_smust now exceedpass_timeout_s + timeout_s(the defaults do). Timed-out attempts are retried with backoff and marked dead aftermax_attemptsas before, and counted in the new metriclorebase.webhooks.timeouts(operations). - The compose
db-rolesstep grants the application role EXECUTE on all eight security definer read functions of a database migrated before the role existed (it granted only the four of revision 0005), reading them from the catalog like the Helm chart's role setup does. - Guide no longer overshoots its token budget by up to the steps times
max_result_chars: before each model call it estimates the input (prompt, messages and tool results so far) and calls only when used plus estimated input fits inguide.agent.max_total_tokens, with the completion cap cut to what remains after the estimate. Thesessionevent now names the chatmodeland the effectivemax_stepsof the run (guide). - Outbox pruning keeps up with high event rates: a pass deletes batches of
store.outbox.prune_batchevents, each in its own transaction, until nothing is left or the newstore.outbox.prune_budget_s(5 s) is spent, instead of one batch per pass. The new gaugelorebase.outbox.prune.backlogreports the events still prunable after a pass (counted up to 100,000) and the alertLorebaseOutboxPruneBacklogfires when it stays above 50,000 for 30 minutes (operations). - The per-session step cap of the agent trace holds across Keeper replicas, and listing sessions no longer scans the steps of the retention period (agent trace). Migration
0029addsagent_trace_sessions, whose step count the trace writer reads and raises under the row lock in the transaction writing the steps (steps overkeeper.trace.max_steps_per_sessionare dropped and counted assession_full), andagent_trace_session_domains, a summary per session and Domain (first and last step, calls, notes, tools, Proposals; indexed on principal and on Domain with the last step) that sessions are listed from under the same visibility rules as steps. Pruning lowers both and recomputes the summaries from the steps left; both tables are filled from the existing steps.
Security¶
- Derived values whose sensitivity is inherited from other Kinds are masked unless a Kind-narrowed clearance covers every source Kind, on every read path: Records, lists, queries, search, history, lineage, diffs, explain and context packs, as in the Derivation preview. Filtering, ordering or grouping by such a value needs the same clearance (403
sensitive_attribute). Domain-wide clearances are unaffected; webhook clearances are Domain-wide and change feed events carry no values (design 13.5). - The structured output mode memory stores only keyed digests, never an endpoint address, and its table grants the application role no TRUNCATE, REFERENCES or TRIGGER. Closing the evidence gap for vaulted values leaves one residual inference: a reader of suggestions can tell that a quote they chose matched from the missing evidence (threat model).
- Vault keys rotate (design 13.5).
lorebase admin vault rotate(POST /api/v1/vault/rotate, permissionvault.manage, held by installation administrators only) queues thevault.rotatejob, which seals every live vault row of every Domain again undervault.encryption_keyand makes its fingerprint again under the current token key,vault.rekey_batchrows per transaction, continuing where it stopped;lorebase admin vault status(GET /api/v1/vault/keys) counts rows per key and names the earlier keys no row uses.vault.token_keynow rotates too: give the new key atoken_key_idand keep the old one inprevious_token_keys; tokens already stored keep working and a value written again finds its old token, so nothing reads as changed. - Erasure covers what it missed. Erasure by value compares matching forms (case, compatibility characters and white space ignored), so
Ada@Example.comandada@example.comare erased together. Erasure by key follows key changes: a commit that deletes a Record and writes another of its Kind holding its vault references (a key changing Operation, say) now stores those values again under the new key instead of being refused (once per reference, into a Record that does not exist yet), and records the lineage that erasure follows both ways. Erased values leave a tombstone (their fingerprint): a later write of one by a Source sync, extraction or an import (vault.tombstone_origins; writes of people and agents are not checked, so they cannot test whether a guessed value was erased) is stored erased (vault.erased_values: erase, the default), refused (refuse, issuevalue.vault_erased) or stored again (allow, the old behaviour). Commits that store vault values and erasures take the Domain's vault lock (shared, waiting at mostvault.lock_timeout_ms, and exclusive), so a value is written either before an erasure, which erases it, or after, which finds its tombstone. The audit entry of an erasure by key holds a keyed digest of the key, no longer the key. - The parameter digest of Operation invocations is always keyed: under
vault.digest_key, else the vault token key (as before), else a random secret that migration0027stores ininstallation_secrets; it was a plain SHA-256 without vault keys. - Health reports no longer list which Records share a sensitive unique or title value to callers without the clearance: they count the groups and Records (
duplicates.hidden). Validation issues about values the caller may not read carry the genericvalue.invalidcode. A vault reference nested inside another Attribute's value is refused (value.vault_reference). The NDJSON diff stream and Operation edits show vault values,[erased]or the mask, as other reads do, instead of raw references. - Operation sensitivity checks resolve the fields an expression reads against the Kind the type checker infers (comprehension variables over
links()or lists of Records, conditional results, nested traversals,record($step[n])), so a same-named sensitive Attribute on another Kind no longer refuses a harmless Operation; only fields of untyped (dyn) values are still matched by name. Evaluation errors are shown in full to callers cleared for the Attributes the expression reads on the Kinds that hold them, not only to callers cleared Domain-wide. - Branches forked before the default branch tagged an Attribute can take its sensitivity tags:
POST /domains/{d}/branches/{b}/rebase-tags(lorebase branch rebase-tags BRANCH) commits the tags (never removing one) and recomputes the titles and search vectors the branch stored before them. Withrows_only, or automatically for every other branch when the default branch tags an Attribute (store.materialize.refresh_branches_on_tag, on by default), astore.sensitivity_refreshjob rewrites those rows in resumable batches. Extraction decides what to extract by the effective tags. - Evaluation errors of Operation steps (a value that does not fit its Attribute, a created Record that exists already) are redacted when the step reads or writes values the caller may not read, and an expression the redactor cannot parse is redacted rather than shown.
python -m lorebase_store.bundle export --include-vault-valueswrites the vault values of the exported Records in plain text (orerased) invault.jsonl, listed in the manifest, readable by its owner only and audited asbundle.export_plainwithout the values; import seals them under the target's vault keys after checking that each token is held by that Record and Attribute in the bundle. A restore from a bundle no longer has to lose vault values.- Read auditing gains
keeper.auth.audit.reads.on_overflow: reject, which answers a read with 503 andRetry-Afterwhen its record cannot be queued and keeps retrying failed batches (turning readiness failed) instead of dropping them, so the read stream can serve as proof. - Operation edits resolve only vault Attribute values bound to their own Kind and Attribute, on a copy of the changes; revalidation issues on values the caller may not read are counted per Attribute instead of listed by Record; markdown context packs and explanations resolve vault references from structured data, never by matching text; writes that show sensitive values say so in their audit entry; Conflicts show vault values instead of references.
- Sensitivity refresh jobs follow the effective tags (derived title and searchable Attributes included), also run for tags on Attributes new to the default branch and for copies of stale branches, are no longer swallowed by a running job, and are queued only for branches with stale Kinds; the tag rebase lists added tags only to callers who may read the default branch's schema.
- Erasure takes the Domain's vault lock first (
vault.erase_lock_timeout_ms, then 503) and reads the key changes under it, so a key change committing meanwhile is erased too; it follows any number of key changes, in batches, and flags an unusually long lineage in its audit entry instead of refusing. A token a key change took over is marked moved (migration0033) and can be neither taken over again nor claimed back. - Bundle exports with
--include-vault-valuescarry the vault's key changes and erasure tombstones, read under the Domain's vault lock and audited at start and end; import refuses tombstones made under a token key it does not have, checks values against the bundle's schema and against tombstones asvault.erased_valuessays, and treats a Domain with leftover vault rows as not empty. - New Domain permission
pii.import(onlyadminamong the built-in roles): values of vault Attributes can be fed through the built-in import and through Sources (defining or mapping such a Source, triggering its sync) only with it, checked before anything is written, so callers without it cannot test whether a value was erased. - Vault bundles carry the
moved_toof key-changed tokens and legacy tombstones (as<token_key_id>/1, noted in the manifest), accept only key changes between Records the bundle holds, record each vault import in abundle.import_vaultaudit entry with the count of oversized key-change groups, and hold the vault lock only while the export's snapshot is taken. - Erasure makes outdated fingerprints current before taking the vault lock and checks again under it; a key change replayed on another branch is allowed as the same move, a later rename carries the pointer of earlier moved tokens along, and erased references move without being marked moved.
- Reviewer edits of extraction items that set a vault value need
pii.import, Scribe never auto-accepts extraction items holding vault values, andpython -m lorebase_keeper sources vaultlists Sources that map vault Attributes so ones defined beforepii.importcan be confirmed or disabled. lorebase admin erase --valuetakes-(standard input, asked without echo on a terminal) or@FILE; a value on the command line is refused, as it stayed in the shell history and the process list.- Keeper sends
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none',Referrer-Policy: no-referrerand, over https (directly or from a proxy inkeeper.trusted_proxieswithX-Forwarded-Proto: https),Strict-Transport-Security;keeper.security_headersconfigures or turns them off. The web image sends a Content Security Policy whoseconnect-srcholds the Keeper origin of the runtime configuration (LOREBASE_WEB_CSP_CONNECT_SRCadds sources,LOREBASE_WEB_CSPreplaces the policy,LOREBASE_WEB_HSTSadds HSTS), and the Helm chart has the same throughweb.security. The web image'sReferrer-Policychanges fromsame-origintono-referrer(deployment). - The application role can no longer change the audit log: migration
0015revokesUPDATE,DELETEandTRUNCATEonaudit_logfromPUBLIC, fromdatabase.application_roleand from every other role but the table owner, and the composedb-rolesscript no longer grants them. Entries are still appended and read as before; nothing in Lorebase prunes the audit log, and an operator who must do so works as the owner (deployment). - Document intake by URL no longer trusts the allowlisted host's DNS: Keeper resolves the host once, refuses it with 422 unless every address is global unicast (loopback, private, link-local, shared and other non-global ranges are refused, as for webhook targets), and connects only to the checked addresses, so a changed DNS answer cannot reach an internal service.
keeper.documents.url_allow_private_targets(false) turns the check off for hosts on an internal network. - Scribe parses PDF and DOCX documents in a child process without network access, stopped after
scribe.extraction.parser.timeout_s(60 s) of wall-clock time,cpu_s(60) of CPU time ormemory_mb(1024 MiB, where the system enforces address-space limits), and refuses documents abovemax_input_bytes(50 MiB). A crafted document that hits a limit fails its extraction job with a message naming the setting instead of holding the worker;isolated: falsereturns to parsing in a worker thread (extraction). - Webhook signing keys can be stored encrypted: with
webhooks.encryption_key(asecret://reference to at least 32 characters, the same for Keeper and Scribe) new keys are sealed with AES-256-GCM, the delivery worker seals the keys stored before, andprevious_encryption_keyslets the key change without breaking deliveries. Receivers change nothing. Migration0016addswebhooks.sealed_key; its downgrade refuses while sealed keys exist (agents). - Supply chain: the base images of the backend and web images (and the compose proxy image) are pinned by digest with the tag kept readable, and moved by hand (release); the build arguments
PYTHON_TAG,NODE_TAGandNGINX_TAGare replaced byPYTHON_IMAGE,NODE_IMAGEandNGINX_IMAGE. A CI workflow (audit.yaml) runs pip-audit on the locked Python dependencies andpnpm audit --prodon every pull request and weekly, failing on advisories not accepted in.github/audit-allowlist.yaml;make securityuses the same check.
Not done yet¶
- The performance baseline on the 1M-Record fixture: the neighborhood on branches other than
mainis just over budget when the working set does not fit in memory (performance). - The performance targets of the design at 10M Records: the 2-hop neighborhood, full-text search (searches of very common words above all), Record history and lineage are over budget, and a commit of 1,000 changes takes 3.3 s (performance).
- Full RBAC: groups from the OIDC groups claim, Kind-scoped grants on reads and sensitivity masking are done; Kind-scoped invisibility in queries is not (roadmap).
- The stability guarantees for
/api/v1: the policy is written and binds from 1.0; Keeper sends noDeprecationorSunsetheader yet, since nothing is deprecated (API stability). - Packages on PyPI: the wheels and sdists are attached to the GitHub release.
Upgrade notes¶
In this order: configuration and secrets, then the migrations, then the restart and the one-off jobs. Keeper and Scribe of 0.1.0 must not run against the migrated database, except as noted for rolling upgrades.
Before upgrading: configuration and images
- Compose: run
make envonce to addLOREBASE_WEBHOOK_KEYto.env; values already set there are kept. Helm: setsecrets.values.webhookEncryptionKey(or thewebhook-encryption-keyentry of an existing Secret), orconfig.webhooks.allow_unsealed_keys: truefor development; the chart refuses to render otherwise. Elsewhere, set the samewebhooks.encryption_keyfor Keeper and Scribe. - Helm database roles: give the Secret
db-maintenance-dsn(the owner, the olddb-dsn) and pointdb-dsnat the application role (lorebase_appby default; the migration Job creates it when the owner hasCREATEROLE). Withpostgresql.enabled, setsecrets.values.dbAppPassword(or create thelorebase-db-appbasic-auth Secret); onehelm upgradeis enough, as thepg-roleshook declares the role before the migration Job. With restricted egress, allow the API server (networkPolicy.egress.kubernetesApi).database.allowSharedRole=truekeeps one DSN, for development only. - Delete
read.history.lineage_max_changesfrom configuration files. - Choose a storage profile with
store.storage.profile(composeLOREBASE_STORAGE_PROFILE, Helmconfig.store.storage.profilewith the Postgres settings ofci/storage-<profile>-values.yaml); without one, the indexes stay as they are. - Scribe: keep
scribe.worker.heartbeat_interval_sat most half ofstore.jobs.stale_after_seconds(a deployment that sets only the heartbeat above 45 seconds must lower it or raise the threshold), andSCRIBE_STOP_GRACE(compose) orscribe.terminationGracePeriodSeconds(chart) abovescribe.worker.shutdown_grace_splusshutdown_cancel_s(35 seconds by default; the defaults are 45 and 60). - Callers that read sensitive values need
pii.read,confidential.readorrestricted.readthrough a custom role inkeeper.auth.authz.custom_roles. To use the vault, set the samevault.token_keyandvault.encryption_key(secret references, 32+ characters) for Keeper and Scribe; rotate the token key withtoken_key_idandprevious_token_keys, never by replacing it. - With more than one Keeper outside Helm, set
keeper.limits.rate.backend: postgres. - Images: make
ghcr.io/aantenore/lorebase-mlreachable where Scribe and Guide run, or set HelmimageMl.repository: "". Give Guide a writable state directory (see Breaking changes). - Web image: a
VITE_KEEPER_BASE_URLgiven only as a build argument does not reach the Content Security Policy; set it at run time or list the Keeper origin inLOREBASE_WEB_CSP_CONNECT_SRC.
Migrations 0013 to 0036
Run them (alembic upgrade head; the compose migrate service and the Helm hook do it) before restarting Keeper and Scribe, with database.application_role set when not on compose or Helm, so that the grants of the application role follow. On compose, db-roles keeps UPDATE and DELETE on audit_log and audit_read_log revoked on every start.
0013web sessions;0014embedding jobs.0015makesaudit_logappend-only for the application role (revokesUPDATE,DELETEandTRUNCATE); revoke them yourself if the migration runs withoutdatabase.application_role.0016addswebhooks.sealed_key; its downgrade refuses while sealed keys exist.0017background branch copies;0018the per-branch search lexicon (its functions stay for Keepers of an earlier version after0035).0019vault bindings;0020outbox consumers;0021schema suggestions.0022read audit log and shared rate limit buckets.python -m lorebase_keeper audit verify --jsongains astreammember and theaudit_verifyjob result areadsmember.0023agent trace;0024keys embedding vectors by template fingerprint;0025thesnapshotref kind used by reads at past Revisions.0026restores the plain reverse Link index, built concurrently per partition, only on a database that ran its withdrawn first version.0027addsvault.fingerprint_keyand the tablesvault_tombstones,vault_lineageandinstallation_secrets(with a random digest secret). Operation invocations recorded without vault keys keep their plain digest, so replaying one of their idempotency keys answers a conflict.0028deferred Operation events.0029agent trace session rows. A Keeper of an earlier version writes and prunes steps without updating them: turnkeeper.trace.enabledoff on old Keepers during a rolling upgrade.0030mat_schema.path_fanout. Branches with materialized path Derivations, or derived values reading a path, are recomputed once by the first commit on the branch or when a job worker starts.0031thebuildingmaterialization and the build, retry and reconcile columns ofbranch_materializations; Scribe workers of an earlier version must not runstore.materializejobs afterwards.0032mat_records.lineage_revisions; until the backfill below, lineage reads the missing Revisions of earlier rows from the branch history.0033themoved_toof key-changed vault tokens.0034inference_endpoint_modes; downgrading drops it and the modes are learned again from the next rejections.0035lb_search_boundedandlb_search_bounded_overlay, which lexical search calls; the candidates are capped bystore.reads.definer_ceilings.candidates(100,000).0036shared lineage maps (mat_lineage_maps, read throughlb_lineage). On an existing install it shares nothing yet (see After the restart); its downgrade writes every map back into its rows.
After the restart
- Run a job worker for
store.lineage_backfill(Scribe runs it by default;python -m lorebase_store.jobs worktoo): it fillsmat_records.lineage_revisionsof the rows materialized earlier, branch by branch in batches ofstore.materialize.job_batch_rows. - Once every process runs 0.2.0, run
python -m lorebase_store.storage applyas the maintenance role (compose:docker compose --profile maintenance run --rm storage apply) to share the lineage maps and apply the storage profile, thenapply --compact --reindexto give the space back. Both are batched, resumable and leave reads running; the 1M-Record benchmark database took 26 and 7 minutes on a loaded machine.python -m lorebase_store.storage statusshows what is left andprewarmloads the read set into memory. - Run
lorebase admin vault rotateonce: rows stored before keep the fingerprint of their exact value until rotated (an erasure by value rotates the rows it looks at first). - Run
python -m lorebase_keeper sources vault(--checkexits 1 when any is listed): Sources that map vault Attributes and were defined beforepii.importexisted keep syncing on their schedule until you confirm or disable each one. - Existing webhooks keep working; their signing keys are sealed by the next delivery passes.
- Branches forked before the default branch tagged an Attribute take its sensitivity tags with
POST /domains/{d}/branches/{b}/rebase-tags.
0.1.0 - 2026-10-21¶
First release. Lorebase keeps a typed ontology in Postgres and lets teams change it on branches, review the change in a Proposal and merge schema and data together, with Conflicts resolved per Attribute. Agents read through a REST API that never calls a model, or through the MCP server, and write only through Operations; a write that needs review becomes a task branch and a Proposal. Documents are extracted into Proposals whose items reviewers accept or reject. Pre-alpha and pre-1.0: /api/v1 is not frozen and interfaces may change in a minor version. The database schema is at migration 0012. The lines of this first section link the documentation of each feature; from the next release they link the pull request or issue.
Added¶
Schema and versioning (design, schema as code)
- A Domain is a YAML schema document (
format: lorebase.schema/v1) with Kinds, Attributes, Relations, Traits, Operations, Derivations and Sources, checked statically and applied as one schema change set:POST /api/v1/domains/{domain}/branches/{branch}/schema/changes,lorebase schema validate|diff|apply. Attribute ids are stable, so a rename keeps its history. - Content-addressed Revisions, branches, tags, diff and time-travel reads (
?at=<revision>,lorebase diff,lorebase record get --at). - Three-way merge of schema and data with typed Conflicts (attribute value, delete or modify, add or add, missing value, type coercion, cardinality, dangling Link and more), resolved with
ours,theirs,baseor a custom value, one by one or from a file (lorebase proposal conflicts|resolve). Merge policiesprefer, cascade and defaults. The merged data is validated against the merged schema before the commit. - Proposals with reviews, required approvals (
keeper.proposals), Conflict resolution and merge over REST and CLI (lorebase proposal create|review|merge|close). The default branch of a Domain is protected (keeper.domains.protect_default_branch): changes reach it through a Proposal. - Expressions in CEL for Operations (preconditions, steps, review policies) and for
expressionandaggregateDerivations, type checked when a schema is applied (schema.expressions_enabled) and recomputed in the materialized head on every commit.pathDerivations are not computed.
Reads for people and agents (agents, performance)
- Records, Links, the JSON query DSL, traversal, neighborhoods and lexical search over REST (
lorebase record,query,search), with ETags and the serving Revision in every response. Full-text search reads GIN indexes on(domain_id, branch, search)and(domain_id, branch, title gin_trgm_ops)(migration0011,btree_gin), so a search reads only its own branch's matches, however many branches share the partition. Semantic search takes vectors supplied by the caller; nothing computes embeddings. - Context packs within a token budget (
POST .../context,lorebase context), Record explanations with the origin of each value (GET .../records/{key}/explain,lorebase explain) and data health reports (GET .../health,lorebase health), all computed without a model. - A change feed as Server-Sent Events and as polling (
GET .../branches/{branch}/events,.../changes) and signed webhooks (POST .../webhooks,Lorebase-SignatureHMAC). - Overlay branches (store schema notes, migration
0010): a branch created withmode: overlay(POST .../branches, defaultstore.materialize.branch_mode, always for names underagent/,extract/andsource/, and for agent task branches perkeeper.agent_writes.branch_mode) stores only the rows it changes and reads its base as of the fork point. Creating one does not depend on the size of the Domain; it is promoted to a full copy abovestore.materialize.overlay_max_rowsrows oroverlay_max_base_lag_revisionsgenerations of base lag, before a schema change and before its base is deleted.lorebase branch create --mode copy|overlaychooses the mode from the command line. Overlay reads go through_overlayvariants of the security definer functions with the same argument and scope checks. An overlay gets the per-Attribute indexes of its schema on its own rows, built asstore.indexjobs when it is created, and its neighborhood looks Links and Records up with one index scan over both branches (migration0012); on the 100k-Record baseline search, attribute queries, Links of a Record and the neighborhood stay within 30% of a copy at p50 (performance). - Per-Attribute indexes and security definer read functions that keep Domain isolation under row-level security. At 99,900 Records and 302,940 Links on one laptop, the measured p95 of every agent read stays within its budget (performance); the 1M-Record baseline is not measured.
Writes and ingestion (quickstart, scribe, inference)
- Built-in Operations
import_records,import_linksandimport_batchwith dry run and idempotency keys, and custom Operations declared in the schema (POST .../operations/{operation}/execute). lorebase import records|links|batchload CSV, TSV, JSON Lines or JSON (Parquet with theparquetextra): types are converted from the schema, a Record travels with its Links, files are cut into batches of one Revision each.- Schema inference from CSV, TSV, JSON, JSON Lines (
.jsonl,.ndjson) and Parquet uploads, chosen by suffix (keeper.schema_inference.file_formats), and from Postgres tables (POST .../schema:infer): Kinds, typed Attributes, keys, Relations with cardinality, a confidence and the evidence for every decision. It proposes and writes nothing. Parquet uploads need thelorebase-keeper[parquet]extra; without it they are refused with a 422 naming it. A record profiler (lorebase_store.profiling.profile_records,RecordProfiler) andprofile_sourcein Scribe profile any Source through its connector; limits are instore.profiling.records. - CSV and Postgres Sources with snapshot and incremental syncs run by Scribe, the worker and scheduler on the Postgres job queue. A sync commits on a Source branch, opens a Proposal and applies the Source's merge policy (
proposal,auto_when_clean,auto_with_authority); every changed value keeps its row as Lineage (lorebase sources sync|runs). - JSON, JSON Lines, Parquet and HTTP JSON Sources (scribe): the
jsonandjsonltypes and therecords_path,flatten_separatorandhttpoptions (headers as secret references, cursor, page, offset or Link header pagination, a watermark parameter and a page cap). Files are opened under the allowed roots without following links, URLs are checked against private addresses, JSON Lines and Parquet are streamed, and an HTTP JSON run that passesmax_pagesfails instead of committing part of a snapshot. Parquet needs thelorebase-scribe[parquet]extra. - Document intake and tiered extraction into Proposals (extraction):
POST .../documentstakes Markdown, plain text, PDF and DOCX (PDF and DOCX with the scribedocumentsextra); Scribe parses and chunks them and proposes Records, Links and values with verified evidence, from rules (tier 0, no model), local GLiNER and GLiREL (tier 1) or any OpenAI-compatible model with JSON schema output (tier 2, cached, token budget per job). Reviewers accept, edit or reject items one by one or by filter (GET .../proposals/{p}/items,POST .../items:decide,lorebase documents upload,lorebase proposal items|decide); nothing reaches the default branch without a merge. Derived and sensitive Attributes are never extracted.GET .../documents/{id}/text(lorebase documents text) serves the parsed text the evidence offsets index. The web app has a Documents screen (intake, library, a viewer that highlights the evidence in the parsed text) and an Items tab on extraction Proposals to accept, edit or reject items one by one or by filter. - Inference providers behind one OpenAI-compatible interface for chat and embeddings, plus local fastembed, GLiNER and GLiREL (optional extras), and compose profiles for Ollama and a LiteLLM proxy. Extraction uses them; nothing computes embeddings yet.
Agents and access (MCP, operations)
- Herald, the MCP server, over stdio and Streamable HTTP with 11 tools (
lorebase_context,lorebase_query,lorebase_executewith dry run and more), two resources and one prompt. It is a thin layer over Keeper and holds no database credentials.lorebase_executepassesintent,auto_proposeandauto_mergeto Keeper also when the call names notask, and returns Keeper's problem text unchanged;auto_propose: truewithout ataskis refused asvalidation-failed. - Agent writes that need review (agents): an Operation refused with
409 review-required(protected branch or review policy) runs, when the call names atask, on a task branchagent/{principal}/{task}and opens or updates one Proposal, merged at once only whenkeeper.agent_writes.allowed_roles_for_auto_mergeallows the caller's role, nothing conflicts and the required approvals are in. Without ataskthe409carriesnexthints. Settingskeeper.agent_writes, overridable per Domain. - API keys (hashed with a server-side pepper), OIDC bearer tokens, role grants per Domain, Kind, Operation and branch, and a hash-chained audit log (
lorebase apikey,GET .../audit).python -m lorebase_keeper bootstrapcreates the first installation administrator key from the database settings and prints it once; it refuses when an administrator already holds a usable key, unless--force. - Rate limits per principal and request class (
RateLimit-*headers,429withRetry-After) and daily quotas per API key, shown bylorebase whoami(keeper.limits.rate,keeper.limits.quota). Failed authentications are counted per client address and per key prefix;keeper.limits.rate.auth_failures.per_key_prefix_enabled: falsecounts them per client address only, so that someone who knows a key prefix cannot keep that key throttled. - Domain backup and restore bundles (
python -m lorebase_store.bundle export|import) and garbage collection of unreachable Revisions with a dry run (python -m lorebase_store.gc).
Clients and web app (web app, tour)
- The
lorebasecommand line and a Python client, sync and async, checked against the committed OpenAPI document (docs/openapi.json). Exit codes are documented: 0 success, 1 usage or not found, 2 validation or conflict, 3 authentication or permission, 4 server error. - A web app with Explorer, Canvas, Modeler (including a Start from data wizard over schema inference that accepts CSV, TSV, JSON, JSON Lines and Parquet files), Review with a Conflict resolver and an Items tab for extraction Proposals, Documents, Sources and Admin screens. It runs on the mock API or on Keeper, with end-to-end tests against a real Keeper for browsing, Branches, schema changes, Review and Settings. It reads
/config.js(window.__LOREBASE_CONFIG__:apiMode,keeperBaseUrl, an optionalapiTokenor anyVITE_*name) over its build values, so thelorebase-webimage switches to live mode at run time, fromVITE_*variables of the container or the chart'sweb.config. Screens load on demand: the main chunk is 193 kB, vendor libraries sit in their own chunks, andpnpm buildfails when the main chunk or the first load exceedsapps/web/bundle-budget.json. The Modeler and the Sources editor keep the Source options they do not show when they save a schema. - The five-day demo:
make demobrings up a dedicated compose stack and runs the proof scenario of design 3.2 with the CLI, in under a minute once the image is built (docs/demo.md).
Deployment (deployment, kubernetes)
- Docker compose for Postgres 16 with pgvector, migrations, Keeper and Scribe, with profiles
local-models,litellm,observabilityandscale-out(a second Keeper, a Postgres streaming replica and nginx; read replica routing with a freshness guard). - A Helm chart (
deploy/helm/lorebase) for Keeper, a migration hook, Scribe, Herald, the web app, Ollama, Ingress, autoscaling and read replica DSNs. The Postgres parameters of the CloudNativePG cluster are explicit (postgresql.cluster.postgresql.parameters,max_connections: "100"by default); size it above Keepers x (pool_size+max_overflow) plus Scribe, migrations and administration. It passeshelm lint --strict,helm templateand kubeconform; it has not run on a live cluster. - An observability bundle in
deploy/observability: Grafana dashboards for Keeper, Store and Scribe, Prometheus alert rules with a runbook per alert and the OpenTelemetry Collector configuration (operations). The composeobservabilityprofile loads them; the Helm chart renders them as ConfigMaps withobservability.enabled=true. - A metrics catalogue (docs/metrics.md) with the Prometheus series, labels and source of every metric, among them the response cache, authentication failures, commit duration, merge attempts, security definer read calls, jobs claimed and queue depth, webhook deliveries, garbage collection runs and bytes freed, and extraction items. A test fails when a dashboard or alert rule names a series that is not emitted, or when an instrument is missing from the catalogue. Latency histograms declare explicit bucket boundaries from 5 ms to 10 s.
- Release artifacts: images
ghcr.io/aantenore/lorebase(Keeper, Scribe and Herald share it; the command picks the service) andghcr.io/aantenore/lorebase-web, tagged0.1.0,0.1andlatest, signed with cosign keyless and carrying a CycloneDX SBOM attestation; CycloneDX SBOMs and the Helm chart package on the GitHub release. The nine Python workspace packages go to PyPI only when the maintainers configured publishing.
Documentation
- A documentation site at aantenore.github.io/lorebase, built from
docs/with Material for MkDocs (make docs,make docs-serve) and published on every push tomain. The command line and configuration reference pages are generated from the code (make docs-generate;scripts/docs/generate.py --checkfails on a stale page) and the REST API page renders the committeddocs/openapi.json.
Security¶
- A security review of the trust boundaries before this release (docs/security-review.md) fixed all 23 findings, among them: a Source could resolve any secret reference, including the database DSN (critical), Source errors echoed resolved secrets,
keeper.auth.mode: nonecould be served on a non-loopback address (now refused unlesskeeper.auth.allow_insecure), webhook targets in non-global address ranges were accepted, and a slow webhook target could stall delivery for every Domain. - Compose: Keeper and Scribe connect as an application role without superuser or
BYPASSRLS(LOREBASE_APP_USER,LOREBASE_APP_PASSWORD, created by the one-shotdb-roles), so row-level security applies to them; the owner role runs the migrations and index maintenance. Thelitellmandobservabilityprofiles refuse to start whileLITELLM_MASTER_KEYorGRAFANA_ADMIN_PASSWORDis empty,change-meor shorter than 16 characters. - Migration
0008adds argument, scope andsearch_pathchecks to the security definer read functions and refuses to run while the application role may create objects in their schemas; migration0009adds client network allowlists to API keys (allowed_networks,lorebase apikey create --allow-network, behindkeeper.trusted_proxies). - Bundle import is bounded and refuses links, schema inference sources are configured per Domain, the OIDC issuer and signing keys need
https, CSV Sources check the resolved address and open files without following links, failed authentications are rate limited per client address and key prefix, the Helm NetworkPolicy is on by default with narrow egress, provider error text is scrubbed, and open event streams are authorized again at an interval.
Not done yet¶
- Lineage and history endpoints beyond the lineage summary on Record reads.
- The 1M-Record performance baseline and the 10M-Record targets of the design.
- Merge policies
authorityandnewest_wins. - Schema suggestions from extraction; embedding jobs and hybrid search (semantic search ranks stored vectors against a query vector the caller supplies);
pathDerivations. - Events declared by Operations (the outbox and signed webhooks carry Revision events only), sensitivity masking, the PII vault, and Traits in time-travel reads.
- Web app: OIDC login with BFF sessions, the Operation, Derivation and Source editors of the Modeler, and a Canvas that renders 5k nodes and 10k edges smoothly.
- A command or endpoint that verifies the audit chain, LinkML import and export, the threat model, the proof scenario as an automated test in CI, and the stability promise for
/api/v1that comes with 1.0.
Upgrade notes¶
There is no earlier release. For a new installation, run the migrations (alembic upgrade head, migrations 0001 to 0012) before Keeper starts: the compose migrate service and the Helm hook do it. Migration 0011 creates the btree_gin extension when it is missing (it ships with Postgres contrib); on a database that already holds data it builds the search indexes concurrently per partition, without blocking writes. Keeper's shipped configuration accepts every request as an administrator (keeper.auth.mode: none); use api_key or oidc for anything shared, give api_key modes a pepper of at least 32 characters, list the administrator in keeper.auth.installation_admins and create the first key with python -m lorebase_keeper bootstrap.