Repository navigation
Lorebase 0.2.0
Pre-releaseSecond 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.