Repository navigation
Releases: pyrite-wiki/pyrite
Release list
v0.26.0
Your files, exactly. The index tells the truth: it keeps one link row per link a file writes, stores a [[short:id]] link under the KB's full name, indexes an entry that carries a YAML timestamp, and reads a field wherever the file spells it. index health lists the files it cannot read, the index rows no file holds and the frontmatter Pyrite cannot model, and marks the index unhealthy instead of passing. A write changes only what was asked (ADR-0042, ADR-0051): create, update, link, rename and schema migrate are operations on the file's own text, so comments, key order, quoting and line endings stay as written, and a request that changes nothing writes nothing. One resolver answers "which KB" on every surface. For instances with more than one user, which stay experimental: a write to a KB entry stays inside the KB root, one landing rule decides where a signed-in user's write lands on every REST route and MCP tool, and the server bounds the size of a request body. The cascade extension, the built-in public site and the Streamlit UI are removed.
Upgrade notes. Run pyrite index build once after upgrading; link counts change (#979, #1009). A shortname is link syntax only: -k, REST kb= and MCP kb_name= refuse it with KB_NOT_FOUND and name the full KB in did_you_mean (#952). Removed (#953): the cascade extension (pip uninstall pyrite-cascade), the built-in public site and the Streamlit UI; the deploy/*/create-user.py scripts give way to pyrite-admin user create (#1066). Writes refuse more than they did: a path that leaves the KB root (OUTSIDE_KB_ROOT), a field Pyrite manages, an unknown REST key, one field at two spellings, a request body over max_request_body_bytes (413, default 32 MiB). A KB's new landing setting (canonical by default, or review) decides where a signed-in user's write lands. A single-entry read puts a declared field at its own key, not under metadata (#1025). index health exits 1 on files it cannot read (#593). pyrite export --format, task -f, MCP legacy_error_code and the search row distance key still work and now go in 0.27. Plugin authors: change an existing entry through KBService.apply_operations; KBRepository.save refuses one. Every change, with the steps in order, is in Upgrade Pyrite.
Added
-
Add or remove one item, or remove a key, without sending the whole field (ADR-0042 decision 1; #955, B6 P3b).
pyrite update --append tags=gamma,--remove tags=alphaand--unset extra; MCPkb_updatewithappend,remove({field: item}; a list value means each item) andunset(a list of keys); RESTPATCH /api/entries/{id}withop: append | remove | unsetbeside the defaultset. The other items keep their bytes: a hand-written link item keeps its spelling, its comment and its extra keys when a link is appended after it (the "appending to a list" example of How Pyrite edits your files now passes).linksandsourcestake item operations on every surface, with an appended link checked as a link; a set of the whole list is still refused. Anullvalue is written asnull; removal isunset.qa fix's link and tag repairs and a task's evidence and audit trail (link_qa_assessment,aggregate_evidence_to_parent,task update --comment) now send one operation each instead of a list read through Pyrite's model, so an item they did not name is never respelled. -
The entry page has a Delete button (#117). The entry toolbar now offers Delete beside Edit (same visibility, hidden while editing). Confirming is a separate, deliberate step: it names the entry and the KB, and neither a double-click nor a second Enter can reach Confirm, because Delete stays disabled and focus moves to the inert prompt rather than to the Confirm button. The step is positioned outside the toolbar, so opening it does not reflow the row. Confirm cannot fire a second request once deleting, and a 404 is treated as already deleted. On success the reader lands back in the KB the delete request named (
/entries?kb=...).
Changed
-
The index keeps one link row per link a file writes (#1009). It used to keep
the first link to each bare target id and drop the rest, so[[ka:x]] and [[kb:x]]recorded only the link toka:x(backlinks x -k kbwas empty),
a second prefix of one target ([[ops:x]] [[ops-kb:x]]) was never checked
byindex health, and a body[[x]]beside a frontmatter link toxwas
lost. Two links are now one row only when they have the same target, KB,
relation and written prefix. Backlink and outlink counts change: a
source that links one entry in two ways is listed once per link in
backlinks,getoutlinks, the graph,links checkand the
broken_linkscount, and![[x]]is now stored as atransclusionrather
than awikilink. Rebuild the index (pyrite index build) to see the new
rows for files that have not changed.
The web backlinks panel and thebacklinkstable list a source once with
each of its links' relations, and the graph draws one edge per pair of
entries. Two spellings of one link (2024and'2024') keep the first
one's note. The overlay backend keeps them apart too: one source's
[[ops:x]]and[[ops-kb:x]]are two backlinks and two outlinks, and
backlink and outlink rows carry the writtenprefixso a merge can tell them
apart (#1062). -
A create now refuses a field Pyrite or the type manages (a task's
status_change_log,evidence,agent_context,assigned_at; an ADR's
adr_number;file_path,provenance,extra_frontmatter) at every
spelling (top level, dottedmetadata.k, undermetadata; an alias of a
managed field is folded too, though none is registered today) on every
create path: MCPkb_create/kb_bulk_create, CLIcreate -f,task create
and the in-processcreate_entry, instead of writing or silently dropping it
(#1010). Two rules relax this. An in-process owner sets its own field with
create_entry(..., owns=frozenset({...}))(sw new-adrnamesadr_number).
An import (pyrite import,POST /api/entries/import) drops the keys an
export carries about the index and location (kb_name,file_path,
indexed_at,content_hash,created_at,updated_at,outlinks,
backlinks, ...) and a type's
managed fields, and names every dropped key underignoredfor its entry,
so an export imports into another KB and nothing is dropped silently
(#1091; a create stamps new timestamps). TheIgnored:label prints without
Rich markup tags shown as literal text, and the key list is escaped as data
(#1096).
pyrite add FILEstill adopts a hand-written file's own values. RESTPOST /api/entriesrefuses only the
metadataspelling: its body model ignores a managed field sent as a
top-level key (200, nothing written). -
An item appended to a list copies the quoting of the item beside it at
every level:- target: "foo"gets- target: "bar", not a barebar,
in block and flow style alike, for any key (an integer key included). A
neighbour's spelling is used only where it reads back as the value;
otherwise the emitter's own rule applies (#1023).
The same holds for an update that inserts an item into an existing list and
for the item-wise rebuild used when a narrow edit fails its post-check: a
new item is spelled like the item beside it (the flow rebuild too), and a
replaced item in that rebuild is spelled like the item it replaces.
Not copied, and unchanged: a list rewritten afresh because no old item is
kept, and a changed or new pair in the item-wise rebuild of a mapping, take
the emitter's own rule.
Thanks to @ANO3457, whose #1131 this is. -
The write pages describe the write path that exists (#1031).
docs/how-pyrite-edits-your-files.mdno longer says an update re-renders the frontmatter: it names the two examples that still fail, shows what a create writes, and gives theset/append/remove/unsetspelling per surface.docs/agent-write-path.mdsays an undeclared field is written at the top level of the file and has a runnable step for--append,--removeand--unset. MCPkb_update'stagsandmetadatadescriptions now say thatappend/removeadd or drop one tag and that a key inmetadatais the field of that name (ADR-0051). -
Storage performance:
list_entriesbatch-fetches tags in a single statement using_get_tags_for_entriesinstead of issuing per-row queries, eliminating N+1 tag lookups and reducing statement count from 51 to 2 for a standard 50-entry page (#1044). -
For contributors:
verify-redstays advisory (it is not ingateand never blocks a merge) but grades against the merge commit's first parent, so a PR merged since yours was opened is no longer graded as yours, and it names tests-only, inert-only, mixed and empty changes instead of calling all of them "no code change" (#1074, #671). The release dry run (scripts/release.py <version>) names and counts each distinct precondition failure once (#889). Thanks to @masyarakatai and @HarshRajSinghania. -
zettel_graphchanges what it returns (#523).depthis capped at 3:
1-3are accepted and anything else (0, negative, above 3, non-integer)
returns{"error": "depth must be between 1 and 3"}instead of being
accepted. Each entry underneighborsnow carriesid,kb_nameand the
hopdepthit was found at, and is keyed by the bare entry id when it is in
the KB you asked about and bykb:idwhen it lives in another KB. Callers
that looked up neighbours by id alone must use thekb:idkey for
other-KB neighbours. Neighbours in KBs the caller cannot read are never
expanded. -
Frontmatter Pyrite cannot model is refused for writes and reported, with the line (#760). A file whose frontmatter holds a tagged key (
!!str flag: 1), a complex key (? [a, b]), a duplic...
v0.25.8
Commands say what they did, and refuse what they cannot do before acting. The write commands join the outcome contract (ADR-0046): one JSON result by default and an exit code that means it. A usage error is a JSON refusal with exit 2. A malformed request, an unknown --format, a directory or missing input file, or an id held by more than one KB is refused before anything is written. A single-entry read returns what the file holds now, not what the index last saw. Search rows are lean and every search says how strong its match is. Index health follows the KB's declared types and reports type-name drift.
Upgrade notes. pyrite search and pyrite-read search return lean rows by default; --fields '*' or PYRITE_SEARCH_FIELDS='*' brings back the full row (#54). pyrite create, add, update, rename, link and the pyrite links commands print JSON by default; pass --format rich for text. Usage errors are now JSON on stdout with exit 2, not text on stderr. pyrite export collection|site --format is deprecated in favour of --target and goes in 0.25.9. An id that two KBs both hold is refused without -k, where it used to answer from the first KB. The Postgres backend is marked experimental. MCP kb_get and kb_update answer ENTRY_NOT_FOUND / KB_NOT_FOUND; the old code stays in legacy_error_code for this release. For plugin authors: an out-of-tree search backend's search_semantic now takes min_similarity and kb_names instead of max_distance, and must return similarity on each row. There is no shim, so the old signature fails with a TypeError on the first semantic search (see Changed).
Added
One heavy test run at a time per machine (#168): scripts/test-affected --run (and so the pre-push hook), scripts/verify-red.sh and the new scripts/e2e.sh (which npm run test:e2e now calls) take one lock per user, ~/.cache/pyrite/heavy.lock, shared by every worktree. A second run waits in arrival order, naming the holder, instead of running beside it; the lock is let go only when the run's command has ended, and a killed holder never leaves a stale lock (the next run waits for the command it left running, then starts); PYRITE_SUITE_LOCK_SKIP=1 skips it and CI takes none. PW_WORKERS sets local Playwright workers, and scripts/machine_ok.sh gives the conductor's health step a load/memory check with an exit code.
Index and KB health report exact case, underscore and space variants of entry type names per KB, with counts and declared spellings. Drift is a warning only and never rewrites content. Reports are bounded to 100 groups and 50 spellings per group and include totals and truncation flags; type aliases and rewriting are not implemented.
Add a documentation index grouped by reader, link the first tutorial and design principles from entry points, and guard documentation coverage and relative links.
Changed
links discover, links batch-suggest and the kb_discover_neighbors / kb_batch_suggest tools (and their REST routes) now leave out entries that have no body and no summary (blank after trimming whitespace), so title-only stubs no longer fill the suggestions (#200). The exclusion is applied inside the search query, so limit results still come back when enough entries qualify. Pass --include-empty (CLI) or include_empty (MCP, REST) to get them back. kb_search is unchanged: search hides nothing.
pyrite search and pyrite-read search results are lean by default (#54). Each result row now carries id, kb_name, entry_type, title, snippet and the score the mode produced (rank for keyword, similarity for semantic, rank, rrf_score, similarity and legs for hybrid); it used to carry every index column (file_path, summary, metadata, created_at, updated_at, indexed_at, created_by, modified_by, date, importance, status, location, lifecycle) and, in hybrid, distance. The page-level keys (match, match_basis, legs_run, keyword_match, warnings, query, count) are unchanged. To get the old output back: --fields '*', or export PYRITE_SEARCH_FIELDS='*' for every search in that shell; a comma list (title,date,status) picks fields, and --fields wins over the variable. --fields '*' now means every field on every CLI command that takes --fields (get, list-entries, recent, batch-read too); it used to return only id and kb_name outside search. Field names are case-sensitive; a name no result carries (and no search row is known to have) is skipped with a warning on stderr, for the variable as for the flag, and search --files says when it ignored either. An empty --fields '' or --fields ',' now gives the lean row (it used to give the whole row). A script that reads a dropped key now gets a missing key (a KeyError), not a wrong value. --include-body adds body to the lean row. CSV drops its date and importance columns and gains the score column; Markdown output is unchanged; Rich drops the Date column. MCP kb_search and REST GET /api/search are unchanged and still return the whole row. ADR-0034 records the changed default as a proposed amendment.
Every search response says what its page is worth (#668). pyrite search and pyrite-read search JSON, MCP kb_search and REST GET /api/search gain match (strong, weak or none) and match_basis (the reason, in words), plus keyword_match (all_terms, some_terms, none) when a keyword leg ran. match: none means the KB does not contain an answer: say so, do not build an answer from the results. The results are still returned when match is none; the verdict tells you what they are worth. legs_run at the top level names the legs that ran (keyword, semantic, both or neither); it is not the per-result legs. none is only said when the search ran as its mode promises: if the semantic leg did not run (the [semantic] extra is not installed, or nothing in the KBs searched is embedded), the verdict is weak at worst, match_basis says why and legs_run shows it. weak with no results means the search could not run in full or found nothing strong, not that the KB lacks an answer. Each result the vector leg returned carries similarity, the cosine similarity to the query (3 decimal places), the same number on SQLite and Postgres, and each hybrid result carries legs (keyword, semantic or both); a hybrid result both legs returned used to lose its score. distance is kept for this release and is backend-specific (L2 on SQLite, cosine distance on Postgres): read similarity. The verdict is the same on every page of a query and is computed only from entries the caller may read. Its thresholds are measured for all-MiniLM-L6-v2; with another embedding_model, match_basis says uncalibrated model <name> and the vector leg is not judged. The CLI's JSON also gains warnings when there are any (they were on stderr only).
Behaviour that changes with it: the query is now embedded by the configured embedding_model (it used the default model whatever the config said), and so are entries embedded from the write queue; if you set a non-default model, run pyrite index embed --force once. The vector leg's cutoff is a similarity floor of 0.155 on both backends (on Postgres the old cutoff removed nothing). In keyword mode, a page past the end of the matches is now empty instead of being refilled with OR-relaxed results. Hybrid's keyword leg relaxes to OR when the vector leg cannot run. Hybrid's keyword leg normally requires every term, because the vector leg supplies the near misses. When the vector leg cannot run (the [semantic] extra is not installed, or nothing in the KBs searched is embedded) there is nothing to supply them, so the search is a keyword search in full: if no entry has every term it retries with the terms OR-combined, as --mode keyword does. It used to return nothing, on the default mode of kb_search, for a query keyword mode answers. A scoped search across all KBs no longer comes back short when nearer entries sit in KBs the caller cannot read: the readable set is applied inside each leg's own query, on SQLite inside the vector search itself, so the rows and the verdict are those of the readable KBs however many nearer entries sit elsewhere. Also on SQLite, candidates are the L2-nearest vectors and are then ordered by cosine, so with an embedding model that does not normalise its output a long vector with a high cosine can be missed (the default model normalises). pyrite links discover and the link-suggestion API show a different score for a semantic match: it is now the cosine similarity, where it was 1 - distance / 2.
For plugins: the rows PluginContext.search_semantic returns gain similarity (and still carry distance). That method now runs a vector search with the configured model; it called a method that did not exist and always fell back to keyword search. Breaking, for an out-of-tree search backend or a caller of one: SearchBackend.search_semantic(embedding, kb_name, limit, max_distance=1.3, ...filters) is now search_semantic(embedding, kb_name, limit, min_similarity=0.155, ...filters, kb_names=None): max_distance is gone (a cosine similarity floor replaces it), kb_names is new, every returned row must carry similarity, and a backend that could not consider every candidate returns a SemanticRows with capped=True. has_embeddings() is now has_embeddings(kb_name=None, kb_names=None). There is no shim: a backend with the old signatures fails with a TypeError on the first semantic or hybrid search, and a caller passing max_distance= fails the same way. EmbeddingService.search_similar changes in step (min_similarity, kb_names).
pyrite export collectionandpyrite export sitename the destination with--target(#873).--formatis the shared output-format option of the outcome contra...
v0.25.7
The first hour works. Following the README and getting-started on a clean machine, then connecting Claude Code or Claude Desktop with pyrite mcp-setup, now does what the docs say: mcp-setup writes the config each client reads, at the write tier; default output is the command's result, without log lines; kb_orient is true as an agent's first call; and the tutorial is run as a test. This is the alpha release for one operator working locally with a terminal agent. Multi-user stays experimental.
Upgrade notes. Run pyrite index build once after upgrading: a save could delete a body's first line when it looked like a frontmatter field (#636), and an index built before the fix still serves the shortened body. pyrite-admin mcp with no --tier now runs the write tier, not admin. The local Docker compose file publishes on 127.0.0.1 only. A parent task is no longer written done when its last child resolves; completion is derived where it is read. The task commands print JSON by default: pass --format rich, or set PYRITE_FORMAT=rich, for text. The journalism-investigation tools are marked experimental.
Added
docs/how-pyrite-edits-your-files.mdsays what Pyrite promises when it edits one of your files: it changes what it was asked to change and nothing else. Its seven examples run as tests against the realkb_update. Today Pyrite keeps none of them: an update re-renders the whole frontmatter, an echo of a read rewrites the file, two concurrent writers lose a change, a read carries no content hash, so a stale replace cannot be refused, and an id-less file is addressed by its title. Each is recorded as an expected failure, and the page says so. (#695)
A type can declare field_aliases: {participants: actors} in kb.yaml or in a plugin's get_type_metadata(), naming the key older files use and the field it stands for. pyrite schema validate reports a malformed map, and a target that is not a declared field. The declaration is read and validated now; Pyrite honours a kb.yaml alias when it writes a file in a later release (B6), and a plugin's alias is what the type's class already reads. The FRONTMATTER_ALIASES class attribute is deprecated and logs a warning; it will be removed after one release.
A create or update that names a field and its alias with different values (participants and actors, source and source_entity) is refused with an error naming both keys and writes nothing; equal values are accepted and write one key. An empty value sent counts as given, and one spelling sent at the top level and again in metadata is held to the same rule; REST create no longer fills participants, tags or metadata the client did not send (ADR-0042 decision 6, #720).
pyrite ids missing -k <kb>lists every file with noid:line, with the id it has today (from its title), files that share an id, and files it could not read.pyrite ids pin -k <kb>adds exactly one line,id: <that id>, as the last line of each such file's frontmatter, and changes no other byte (CRLF, comments, key order and quoting are kept; the result is re-read and the file refused if anything else would change).--dry-runprints the plan and writes nothing. Files that share an id are never resolved for you: the group is left untouched until you keep one and--rename <path>=<id>the others.ids missingand--dry-runwrite nothing anywhere (no file, no index, no config directory). A symbolic link, a file in a linked folder, a hard-linked or non-regular file, and a file edited after it was read are reported and never written. Both print JSON by default (--format textfor reading); both exit0when nothing is left,3when files remain outside the id contract,1on an error. Before the release that switches id-less files to path ids, follow docs/pinning-entry-ids.md so nothing that links to them changes (ADR-0042). (#700)
atomic_write_text takes expect= (the file's expected bytes, a sha256 hex digest, or ABSENT). Under a new momentary lock (pyrite.utils.file_lock) it compares the file with expect immediately before replacing it, and when they differ it writes nothing and raises FileChanged. The lock lives where git locks: in <git-dir>/pyrite/locks of the repository whose work tree holds the file (a linked worktree uses its own git dir). It is found by git's own discovery rules, independent of GIT_DIR and other environment variables, and created with the git dir's permissions, never wider. One file has one lock whatever path reaches it (symlink, .., case or Unicode spelling, the macOS /System/Volumes/Data firmlink): the key is the parent directory's identity plus the name. An expect= write to a file with more than one hard link is refused (HardLinked), and every wait for the lock is bounded (LockTimeout; an unbounded timeout is a ValueError). A KB that is not in a git work tree is refused for expect= writes with NotInGitRepository, as is a git dir this user cannot write. Not supported on Windows. Nothing calls it yet (ADR-0042 §10a A1, B6 P2).
-
Claude Code on the web sessions set themselves up for Pyrite work: a SessionStart hook in the now checked-in
.claude/settings.jsonrunsscripts/cloud-session-start.sh(venv, extensions, repo-local KB config, index, git hooks).scripts/setup-checkout.shis the one install path it shares withscripts/new-worktree.sh. See "Developing in Claude Code on the web" in CONTRIBUTING.md. -
Cloud environments take a one-line setup script,
scripts/cloud-env-setup.sh, that builds the venv, hook environments and Postgres image once (cached as a snapshot); the session hook then only updates and starts services. Cloud sessions run the Postgres conformance tests against the samepgvector/pgvector:pg16container CI uses (PYRITE_SETUP_POSTGRES=0opts out). -
"Pyrite in 20 minutes", a tutorial that is its own acceptance test.
docs/tutorials/pyrite-in-20-minutes.mdtakes a newcomer from nothing to asking Claude about Pyrite's own knowledge base and the 26 demo KBs, to a research KB of their own written by an agent, to building a third. CI and the release check run it withscripts/run_tutorial.sh docs/tutorials/pyrite-in-20-minutes.md, which now also reads<!-- runner: -->comments for the entries a search must find, a command that must be refused, and a fixture that stands in for the agent's step.
Changed
-
pyrite-admin mcp,python -m pyrite.server.mcp_serverandpyrite-mcp servenow default to thewritetier, likepyrite mcp(ADR-0006).pyrite-admin mcpwasadminand the other two wereread.pyrite-admin mcpalso rejects an unknown tier withINVALID_TIER. A hand-written MCP config that runspyrite-admin mcpwith no--tierand relied on the admin tools (kb_push,kb_registry_*,kb_commit, ...) must add"--tier", "admin"to its args. A config that runspyrite-mcp serveorpython -m pyrite.server.mcp_serverwith no--tierwas read-only and now also gets the create, update and delete tools; add"--tier", "read"to its args to keep it read-only.pyrite-admin mcp-setupis removed (it now exits 1 and points topyrite mcp-setup): it ignored--tierand wrote three servers (pyrite-read,pyrite-write,pyrite-admin) to a file no client reads. Usepyrite mcp-setup --tier <tier>. It leaves those three entries where they are and names them in its report; remove them by hand if they are not yours. (#582) -
A command's default output is its result.
pyrite,pyrite-adminand
pyrite-readnow log at WARNING by default through one shared function, so
init,create,searchandindex embedno longer print one
Applied migrationline per migration, asearch.queryline per search or
the sqlite-vec line on stderr; stdiopyrite mcpstops filling the MCP
client's server log the same way.-v(INFO) and-vv(DEBUG) restore the
lines from any position on the command line, andPYRITE_LOG_LEVELsets the
level without a flag.pyrite serveandpyrite-serverkeep INFO as the
operator's log.pyrite-admin,pyrite-readandpyrite-servernow print
warnings, formatted likepyrite's; before, the package's inert handler
swallowed them. (#584) -
pyrite search --mode semantic|hybridwithout the[semantic]extra (or
without the sqlite-vec extension) now returns awarningsentry naming
pip install pyrite[semantic]on the CLI, REST and MCP, instead of a silent
empty leg. The "no embeddings yet" warning no longer says "only the keyword
leg ran" in pure semantic mode, where no keyword leg runs. The CLI prints
warnings that contain brackets, such as the install line, intact. (#43, #584) -
pyrite index embedcounts the entries it settled from the embed queue as
embedded instead of skipped: after writes it printedEmbedded: 0, Skipped: 4
for a run that added four vectors. (#584) -
A
-vthat is an option's value (pyrite create -b -v) stays the value, as
before; it is not read as the verbosity flag.pyrite search --debugnow
reports the same skip cause as the warning (reason=semantic_extra_missing,
andactual=nonefor a semantic search that did not run).pyrite index embed --kb Asays how many entries in other KBs the queue drain also
embedded, and its sqlite-vec message no longer suggests installing a package
that is already installed.PYRITE_LOG_LEVELand-vare in
docs/configuration.md. (#584) -
kb_orient/pyrite orientis true as the first call (#66, and the
declared-types part of #232).- Public shape change, MCP error codes. A KB name that is not a KB, or
that the caller may not read, now answersKB_NOT_FOUNDover MCP, the
code REST and the CLI already used (ADR-0037). Two older codes are
replaced: theNOT_FOUNDevery MCP tool, prompt and resource gave a
caller with per-KB scoping for a KB outside its readable set, ...
- Public shape change, MCP error codes. A KB name that is not a KB, or
v0.25.6
Fix release for frontmatter integrity. Upgrade if you run pyrite update, task update, link, MCP kb_update or REST PATCH/PUT on entries with hand-written frontmatter: 0.25.5 could delete or rewrite keys the request did not name.
Upgrade note: enums declared in kb.yaml are now enforced. A KB whose data has drifted from its declared values refuses off-list writes after upgrading; the entry under Changed gives the migration (validation.enforce_enums: false, clean up, then remove the setting).
Added
pyrite qa validatereports a file that uses a Pyrite-reserved key for a field of its own (#557): rulereserved_key_collision(warning), naming the file and the key, when the value does not fit Pyrite's model (provenance: {source: ...},importance: high) and suggesting a rename.
Changed
-
The README, SECURITY.md and the configuration docs now say plainly that multi-user use (accounts, per-KB permissions, anonymous readers, the public
/site, shared servers) is experimental: treat every KB on a shared instance as readable by every user of it. Isolation reports are fixed in regular releases rather than emergency patches. -
Enums declared in
kb.yamlare now enforced: a write with an off-list value is refused by default (#555). Before, avalues:list was silently ignored,validation.rulesentries withenum:were never evaluated, andlistfields were never checked, so validators reported clean over real drift. Nowoptions:(canonical) and its aliasvalues:onselect,multi-selectandlistfields, listitems: {options: [...]}, and rule-levelenum:are all checked, the same way by writes,qa validate,ci,schema validate(the pre-commit hook) andindex health. A new switch,validation.enforce_enums(defaulttrue), decides whether an off-list value is an error that refuses the write (naming the field, the value and the allowed list) or a warning. It is independent ofvalidation.enforce, which still governs every other check.allow_other: truestill makes a field's finding a warning. -
An entry that is already off-list can be updated again (#47). An update that leaves an off-list value as it was, including a whole read echoed back, succeeds and returns that value as a warning; this covers plugin vocabularies such as software-kb's
kindtoo. Changing the field to another off-list value, or adding an off-list list element, is still refused, and create is always strict.pyrite updatenow prints the write'swarningsin its JSON, as REST and MCP already did. -
pyrite index healthreports off-list values under a new key,off_list_values({kb, id, type, field, value, allowed, origin, severity}), including custom fields kept in an entry's metadata. A kb.yaml enum's rows make the statusunhealthy(exit 1) whenenforce_enumsis on andwarningwhen it is off. Rows from a plugin validator's enum are alwayswarning; rows for anallow_otherfield areinfoand leave the status unchanged. -
Shipped schemas no longer constrain one type's field with a KB-wide rule. A
validation.rulesenum applies to every entry with the field.docs/desk-schema.yaml'sstatusrule now lists every task workflow state (claimed,review,failedandcancelledwere missing, so claiming a desk task would have been refused); thesoftwareinit template and the software-kb preset no longer carry an ADR-onlystatusrule (the software-kb validator checks each type's status for a KB withkb_type: software; a KB made bypyrite init -t softwareis registered as generic and has no status check until #572); themovementtemplate'sstatusenum is now a field enum onpractice, so events are not refused.pyrite task createandpyrite importnow print the write's schema warnings, ascreateandupdatedo.schema validatewarns whenvalidation.enforce_enumsis not a boolean (onlyfalseturns enforcement off). -
Upgrade note. A KB that relied on the old behaviour, with data that drifts from its declared enums (including
values:lists and ruleenum:s that used to be ignored), now refuses off-list writes, andindex healthandcireport it as failing. To migrate: setvalidation.enforce_enums: falseinkb.yaml, clean up guided bypyrite qa validateandpyrite index health(off_list_values), then remove the line. A KB created from an older template keeps that template's rules in its ownkb.yaml: if it was made withpyrite init -t software(or the software-kb preset), delete thestatusrule undervalidation.rules(it lists only ADR statuses and now refuses backlog, design-doc and milestone statuses); with-t movement, move thestatusrule totypes.practice.fields.status: {type: select, options: [active, deprecated, evolved]}; a desk made fromdocs/desk-schema.yaml, replace itsstatusrule with the current one.
Fixed
-
MCP
kb_updatestores keys the entry type does not declare, the waypyrite update -fand RESTPATCHdo (#455); it used to drop them and reportupdated: true. Keys akb_getresult carries that no update writes (the id, path, timestamps, managed fields, index columns, andnulls for fields the entry does not use) are not written and are listed in the result's newignoredfield. Anullfor a key the type does not declare is also set aside, sokb_updatecan set such a key but not clear it. -
pyrite index reconcileno longer proposes moving every file whose type sets afile_pattern(#474). It now moves only entries whose type has a templated subdirectory (such asbacklog/{status}), keeps each file's name, and leaves static placements and__collection.yamlfiles where they are. A bad template path is reported for that entry instead of stopping the run. -
Unknown CLI output formats now return a usage error instead of a traceback (#529).
pyrite getandpyrite searchreject unsupported--formatvalues during argument parsing with exit code 2. -
One task with a word priority no longer fails every task listing (#554).
pyrite task listand every cross-KB task listing ended withinvalid literal for int() with base 10: 'medium'when any task in any KB hadpriority: medium. A task's priority is now read on its 1-10 scale wherever it is read:low/medium/high/criticalas 3/5/7/9, a whole number however written (7,'7',7.0) as that number, anything else as the default 5, each with a logged warning naming the task; the file keeps the value as written.pyrite qa validatereports a task whose priority is not an integer from 1 to 10 (ruletask_priority, naming the file), and its schema check now reads the fields a core type keeps outsidemetadata(a task'spriority,assignee,due_date...) from the file as written, which it used to report as missing; akb.yamlthat retypes a task'spriority(a select of words, say) is reported once, naming the file and the field (ruleschema_retypes_core_field). An existing index keeps the old value of an unchanged file, becausepyrite index syncre-reads only files whose content changed: runpyrite index build(orpyrite index build -k <kb>) once after upgrading to re-read every file. -
An update no longer deletes or rewrites frontmatter keys the request does not name (#557). A key whose value Pyrite's model cannot represent -- a
provenance:block of a KB's own shape,sources: a book,importance: high, alinks: [a, b]shorthand -- used to be dropped or rewritten by everypyrite update, RESTPATCH/PUT, MCPkb_update,task updateorlinkon that entry, while the command reported success. The file's value is now written back exactly as it was unless the field itself is changed. Naming a Pyrite-managed key such asprovenanceis still refused, before anything is written, and the message now says to rename the file's key. -
Sending a
kb_getresult back to MCPkb_updatechanges only what you changed (#561). A field still at the value the read returned is not written and is listed in the result's newunchangedfield (apart fromignored, which names keys no update writes), soimportance: highis no longer rewritten as5and a task no longer growsimportance: 5andpriority: '5'. Updating an event'sstatus(echoed or set) no longer fails with'str' object has no attribute 'value'; an unknown status is refused with the allowed values.tags: Foo(andaliases: Foo) is read as one tag instead of its characters, and a bare URL insources:is read as the source's URL. An existing index keeps the character tags and empty source URLs of files that have not changed since, becausepyrite index syncre-reads only changed files: runpyrite index build(orpyrite index build -k <kb>) once after upgrading. -
Frontmatter stays a string for every YAML reader, not only Pyrite's own (#568).
pyrite update -f decision=yesused to writedecision: yesbare; a reader implementing YAML 1.1 (PyYAML'ssafe_load, the most common Python YAML library) resolves that to the booleantrue, not the string"yes"Pyrite itself reads it as. Any string Pyrite writes -- a value, or a frontmatter key, since an undeclared key is stored as given (#407) -- that a YAML 1.1 reader would resolve to something other than a string is now quoted, at any nesting depth and inside list items:yes/no/on/offin any case,true/falsein any case,~/null, and 1.1-only numbers such as sexagesimal1:30,0777,0o17,1_000. A real boolean or number stays unquoted, and a value already quoted in the file keeps its own quoting style. Migration: the next write of any entry re-quotes every such value already bare in that file, not only the field being changed -- a whole-document dump, not a line patch. The value itself does not change, only the bytes that represent it.- Known limit: bare
y/Y/n/Nare left unquoted...
- Known limit: bare
v0.25.5
Security release, from the 0.26 multi-user security review. Upgrade if you run Pyrite with auth enabled, serve /site, or connect MCP clients over HTTP. An MCP session now acts only for the credential that opened it, and ends when that credential is revoked, logged out or expired. Cookie-authenticated writes must come from an allowed origin. Content from knowledge bases is sanitised in the web app, which now sends a content security policy. Changing a knowledge base's access takes effect everywhere at once. Reads of links, lookups, queries, the graph and QA stay within the knowledge bases the caller can read. Config saves no longer write environment-supplied secrets to disk. This release also carries ADR-0037's single authorization policy point and error contract, entry-identity fixes, crash-safe config saves, and four contributor changes (#462, #471, #502, #505).
Upgrade notes:
- Dependencies:
fastapi>=0.132andmcp>=1.27.2,<2. With an older MCP library,/mcpis not mounted and the server logs the required version. - REST errors from the central handler now use the
{"detail": {"code", "message", ...}}shape that other endpoints already used. Readbody["detail"]["code"]. - Cookie-authenticated writes need an allowed
Origin. Behind a reverse proxy that rewritesHost, add the public origin tocors_origins(PYRITE_CORS_ORIGINS). - Access changes on hand-written config KBs: changing the default access of a KB defined in a hand-written
config.yamlnow returns 409, naming the file. Edit the file instead. KBs the server manages (repo-subscribed and ephemeral) remain editable. - Extension authors: link and lookup reads (
get_backlinks,get_outlinksand related calls) now requirereadable_kbs=. Pass the caller's readable set, orUNSCOPED(frompyrite.services.access_policy) only when there is no caller.
Added
pyrite links asymmetric -k <kb>now finds one-way links within a single knowledge base. Passing the same KB with--kb-aand--kb-balso returns each link once; two-KB checks are unchanged (#465).
Changed
-
pyrite rename X X(andKBRepository.rename) now refuses a rename to the same id with a validation error instead of silently doing nothing, which left an unindexed entry invisible (#493). -
Error codes now live on the exception classes (
pyrite/exceptions.py)
instead of being decided separately per transport, closing the gap where
the same exception answered a different code on different surfaces.REST. A
PyriteErrorthat reaches the central handler now answers
in the same{"detail": {"code", "message", "retryable", "hint"?}}shape
everyHTTPException(detail={...})site already used. Before this
release the central handler answered a flat{"code", "message"}body,
so a client readingbody["code"]from those responses must now read
body["detail"]["code"]. Status codes are unchanged. Most responses are
unaffected: 91 of 124HTTPExceptionsites already answer their own
code in thedetailshape and are untouched. The code string changes
for these classes when the central handler answers them:UndeclaredTypeError,EntryExistsError,SchemaViolationErrorand
InvalidGitRefErrornow answer their own codes (UNDECLARED_TYPE,
ENTRY_EXISTS,SCHEMA_VIOLATION,INVALID_REF) where the central
handler previously answered the genericVALIDATION_ERRORfor all
four (their status codes are unchanged).ClipperBlockedHostErrornow answersCLIPPER_BLOCKED_HOSTwhere the
central handler previously answeredINTERNAL_ERROR(both 500).- A bare
ValidationError(and any subclass without its own code, e.g.
TruncatedBodyError) answered by the central handler now answers
VALIDATION_FAILEDwhere it previously answeredVALIDATION_ERROR.
VALIDATION_FAILEDis the spelling REST's write pipeline
(server/endpoints/write_refusal.py, bulk per-item results) and MCP
already used; the central handler's disagreeing second spelling is
gone.
MCP. Where MCP's code used to differ from REST's (
NOT_FOUNDvs
ENTRY_NOT_FOUND/KB_NOT_FOUND,READ_ONLYvsKB_READ_ONLY,
CONFIG_ERRORvsCONFIG_CONFLICT/CONFIG_SAVE_REFUSED,
REQUEST_REFUSEDvsSTORAGE_ERROR/PLUGIN_ERROR, and
VALIDATION_FAILEDvsINVALID_FRONTMATTERforFrontmatterError
specifically), MCP now reports REST's code and carries its own previous
code for one release in a newlegacy_error_codefield, so an existing
MCP caller matching on the old string keeps working through the
transition. The next release removeslegacy_error_codeand MCP's code
becomes the same string REST reports.kb_bulk_create's and
POST /api/entries/import's per-item results, and every extension's
CREATE_FAILEDfallback, now emit the exception class's own code the
same way — for the common case (a bareValidationError, e.g. a missing
title) that code isVALIDATION_FAILED, unchanged.CLI. Core CLI write commands have not adopted the class-level codes
yet (cli_error_from, added by this release, has no call site), so their
output is unchanged; wiring them will emit the exception's own code (e.g.
ENTRY_NOT_FOUND) where several commands today emit the genericERROR
(#481). The extension CLI create commands (zettelkasten,
software-kb) do change: they read the exception'serror_codeand fell
back toCREATE_FAILED, and since everyPyriteErrornow carries a code
(the base's isINTERNAL_ERROR), a failed create there now reports the
exception's own code instead ofCREATE_FAILED.Server log. The central handler's Python logger name changed from
pyrite.server.apitopyrite.server.errors(the handler moved to its
own module); anything grepping or filtering server logs by logger name
for this handler's lines needs to match the new name.StorageError,PluginErrorandConfigErrornow have a fixed,
safepublic_message(ADR-0037 §3) shown over REST and MCP instead of
str(exc), which could carry server-side detail (a database driver's
own text, a plugin's traceback fragment, a real filesystem path) at some
raise sites; the real detail still reaches the server log. Their
subclasses that already had their ownpublic_message
(ConfigSaveRefusedError/ConfigFileUnreadableError, #377) are
unaffected. The fixed message replaces the raw text wherever these
classes reach a caller:- MCP: the
errorofkb_orient,kb_create,kb_bulk_create(whole
call and each item),kb_update,kb_delete,kb_link,
task_decompose,task_checkpoint, schema set,kb_commit,
kb_pushandkb_registry_add; the social create tool, the
journalism-investigation create/log-source/promote tools, and the
software-kb reorder and backlog-create tools. - REST: each item of
POST /api/entries/import, publish's
push_error, the batch link write-back's per-position error, and the
index joberrorfield (adminGET /index/jobs, MCP
kb_index_job_status). - CLI: bulk create (
pyrite import) prints the fixed message for a bare
StorageError/PluginError/ConfigError; the raw text still reaches
the terminal through the log on stderr.zettel newandsw new-adr
printstr(e)unconditionally (#506 -- there is no transport boundary
to protect on the operator's own terminal, so this release never ships
with those two masked).
(ADR-0037 theme 2)
-
Web: the New-entry form and the Web Clipper now offer only a KB's declared
types when it declares any, requiring an explicit choice (auto-selected
only when the KB declares exactly one) rather than an arbitrary default,
with an explicit "use a type this KB does not declare" control as the only
way to reach the rest; the web client no longer sends
allow_undeclared: trueunconditionally on every create, clip or import.
GET /api/entries/type-schemasreports the KB's declared types as an
additivedeclaredfield. (#392)
Fixed
-
Extension writes go through the write pipeline (#391). MCP
social_postandpyrite zettel newused to write entries directly viaKBRepository.save, bypassingKBService's create pipeline: an existing id was silently overwritten, no validators or before/after-save hooks ran, and neither surface indexed the new entry until a separatepyrite index sync. Both now go throughKBService.create_entry, so an existing id is refused withENTRY_EXISTS(the file is left byte-identical), hooks run, and the new entry is searchable immediately. -
sw new-adrgoes through the write pipeline, andfile_patterngains entry-field placeholders (#391).sw new-adrused to write the ADR file directly, bypassingKBService: an existing ADR number or id was silently overwritten, no validators or before/after-save hooks ran, the new ADR wasn't indexed until a separatepyrite index sync, and a KB that failed to resolve silently fell back to writing./adrsunder the current directory. It now creates throughKBService.create_entry: an existing id, or a different id that would resolve to the same on-disk path (e.g. two ADRs given the sameadr_number, or two without one at all), is refused withENTRY_EXISTSand the existing file is left byte-identical; no KB resolved refuses withKB_NOT_FOUNDinstead of writing anywhere.TypeSchema.resolve_filename(file_pattern) now supports the entry's own fields as placeholders, with an optional Python format spec (e.g.{adr_number:04d}), alongside the existing{id}/{slug}/{date}/{title}/{type}; a field the entry doesn't have, or one that is empty/whitespace/None/starts with., falls back to the type's default filename rather than refusing the create, while a pattern or data bug (a format-spec mismatch, a positional or attribute/index placeholder, a NUL byte, an over-long res...
v0.25.4
Security release. Upgrade if you run Pyrite with auth enabled, or if you open Pyrite in directories you did not create (cloned or downloaded trees). With auth enabled, the instance now stays closed to strangers until its operator decides: sign-up waits for an admin created with pyrite-admin user create, self-registered users read only the KBs you open to them, and login and registration are rate-limited. A .pyrite/config.yaml found in a working tree is no longer trusted with anything beyond that tree's own KBs and index. See the operator actions below. This release also carries the write-path fixes the investigation workflow depends on, entry version history for server writes, search and site-cache robustness, and three contributor fixes.
Changed
-
One write pipeline: every create surface refuses the same entry with the same code. REST, MCP and the CLI now share one create/update pipeline in
KBService(#378). A refused write reports a stableerror_codeeverywhere —UNDECLARED_TYPE,ENTRY_EXISTS,SCHEMA_VIOLATIONorVALIDATION_FAILED— instead of a per-surfaceCREATE_FAILED/UPDATE_FAILED; REST answers a duplicate id with409. See docs/json-contracts.md, "Write refusals". -
MCP
kb_createno longer exempts core types from the undeclared-type refusal, matching the CLI (#197): in a KB whosekb.yamldeclares types,entry_type: noteis refused withUNDECLARED_TYPEunless the type is declared orallow_undeclared: trueis passed. RESTPOST /api/entriesand/api/entries/import,pyrite addandpyrite importapply the same refusal, with anallow_undeclared/--allow-undeclaredoverride. -
REST create and update return warnings.
POST,PUTandPATCH /api/entriesresponses carry awarningslist with the schema's non-blocking findings, as MCP already did. -
pyrite importexits 1 when any record is refused, prints each refusal with its code, and--dry-runnow reports the records the pipeline would refuse. The JSON importer carries every field of a record through instead of a fixed whitelist, so the fields of core and plugin types (role,kind,status, …) are imported and validated instead of dropped. Together with #386, the fields of a type declared only inkb.yamlare kept and validated too. -
MCP
kb_updatetakes its field set from the entry's type (its model and itskb.yamlfields) instead of a fixed core list; akb.yaml-declared field of a custom type is now written instead of silently ignored, andid, path and timestamps are never rewritten. -
Updates refuse fields Pyrite maintains. REST
PUT/PATCH /api/entries/{id}andpyrite update --fieldrefuseid,file_path,kb_name,links,sourcesandprovenancewithVALIDATION_FAILED(settingidused to leave a second file), and so do a task's audit fields (status_change_log,evidence,agent_context,assigned_at) and an ADR'sadr_number; MCPkb_updateignores them. Entry types, including plugin types, declare such fields withmanaged_fields. -
The web app keeps its type behaviour. The web client sends
allow_undeclared: trueon create, clip and import, so the New-entry form, which offers every core and plugin type, still works in a KB whosekb.yamldeclares types.POST /api/clipnow takesallow_undeclaredlike the other create endpoints and reports the pipeline's codes (ENTRY_EXISTSis a 409). -
One plugin validator and hook contract, checked once at registration.
PluginRegistrynow checks every plugin'sget_validators()andget_hooks()callables against the single supported signature —(entry_type: str, fields: dict, ctx: dict) -> list[dict]for validators,(entry, ctx)for hooks — the first time that plugin's validators/hooks are needed, and caches the result for the registry's lifetime; a non-conforming callable is refused (logged once, dropped) instead of discovering the mismatch as aTypeErrorswallowed at call time. A 2-argument(entry_type, fields)validator no longer runs at all — the legacy fallback that used to call it is gone; every validator must acceptctxas a third positional argument. A validator that binds the contract but still returns the oldlist[str]shape (instead oflist[dict]) has its non-dict items dropped with a warning rather than crashing a caller. Abefore_*hook (before_save/before_delete) that gets dropped as non-conforming now refuses the write for that KB (fail-closed, matching what a raising hook already did) rather than silently proceeding without it; a droppedafter_*hook only logs a warning, since the operation has already succeeded.HookRunnernow owns both the core-hook and plugin-hook phases under one raise-before/swallow-after contract. -
The
cascadeandjournalism-investigationextensions' validators were on the old, non-conforming signature: their validation was silently disabled since the 3-argument contract was introduced, so acascade-researchorjournalism-investigation/known-entitiesKB now refuses writes it previously accepted silently — anactorwith no title, anassetwith noasset_type, an out-of-rangeimportance, and similar rules each extension already declared but never actually ran. Existing entries already in a KB that violate one of these newly-active rules are not touched retroactively — they are refused the next time they are edited, not flagged automatically. Runpyrite qa validate -k <kb>(orpyrite index health, which reports the same drift forstatusfields) to find entries that would now fail validation before they're next edited. Seekb/standards/extension-development.md, "Validator signature". -
investigation promote-claim(CLI) andinvestigation_promote_claim(MCP) accept the promoted edge's endpoint fields. Promoting a claim toownership/funding/membershipnever set the edge type's own required relationship fields (owner/asset,funder/recipient,person/organization), so every promotion was refused once the journalism-investigation validator started actually running (#424).--owner/--asset/--funder/--recipient/--person/--organizationCLI options and anendpoint_fieldsMCP argument supply them; a promotion missing the required pair is refused with a clear message (not a rawSchemaViolationError), and--dry-run/dry_runnow runs the same endpoint check. The MCP argument accepts only those field names, as strings. A dry run does not yet run the KB's full validation, so a real run can still refuse what it showed (#427). -
MCP: a locked or busy database is
retryable: true(#431). A search that fails because another connection holds the index returnsretryable: true, so an agent may retry it. Every other storage fault (schema drift, a missing table, a corrupt file) staysretryable: false. Theerror_codeis unchanged (REQUEST_REFUSED), and MCP now logs each storage fault with its traceback.SearchService.searchtakes an optionalsemantic_query, the text the semantic leg embeds when it should differ from the keyword query. -
Lowercase
and/or/notno longer turn off quoting, so write operators in uppercase (#431). Before, a lowercase operator word made the whole query pass through unquoted. Now the other tokens are quoted as usual, so intitle:foo and barorfoo* or barthe column filter and the prefix match become literal text. Writetitle:foo AND barorfoo* OR barto keep them as FTS5 syntax. -
scripts/test-affected --runand the pre-push hook no longer re-run tests that already passed on the same code. A pass on a clean tree records a stamp (the tree's SHA, the Python version and the tests run) under the git common directory, shared by every worktree; a later run -- the pre-push hook's, which looks up the tree being pushed -- that the stamp covers printsalready passed on tree <sha> (...); skippingand exits 0. A dirty tree, a failure and a partial run are never stamped.--forceorPYRITE_PUSH_FORCE=1runs anyway. After a load-caused failure,scripts/test-affected --run -- --lfre-runs only the failures. CI's push todevlikewise skips the test matrix and the frontend job when the merge queue already passed that exact commit; the smoke layer still runs. -
verify-redno longer touches your checkout: it runs a pull request's new and edited tests in a throwaway worktree of the merge base, first with only the PR's test files and then with the whole change, uncommitted work included. It reports one line (verify-red: 3 red · 0 import-only · 0 unexpected pass · 1 n/a) and a row only for each test that is not red.@pytest.mark.controlmarks a deliberate negative control. CI also reports advisory diff coverage (80% of changed lines) and keeps both in atest-evidenceartifact (#368).
Removed
PluginRegistry.run_hooksandPluginRegistry.run_hooks_for_kbare gone. Both ran plugin hooks directly; that responsibility moved toHookRunner, which now owns the raise-before/swallow-after contract for both core and plugin hooks in one place.PluginRegistry.get_hooks_for_kbremains, but it is a pure lookup — it returns the hook-point-to-callables mapping and does not run anything. A caller that ran hooks directly through the registry should construct aHookRunner(plugin_registry=...)and callrun_before_save/run_after_save/run_before_delete/run_after_deleteinstead.
Fixed
- A second relation between the same two entries is now recorded instead of silently dropped (#396).
pyrite link a b -r implementsfollowed bypyrite link a b -r supersedesused to reportLinked:for the second call too but write nothing — the duplicate check ignoredrelation. The duplicate key is now(target, kb, relation), normalised so a link written before this fix (or by hand, with norelation:key at all) still matches the CLI/MCP default relation rather than looking like a second, dist...
v0.25.3
Security patch. Upgrade if your server is reachable by anyone other than you, and especially if anonymous visitors can read (anonymous_tier: read/write, or a KB with default_role: read). Search queries now have a maximum length of 1,000 characters. A longer query is refused with QUERY_TOO_LONG on REST (422), MCP and the CLI; it is never truncated. Searches that Pyrite builds from stored content (AI chat retrieval, link suggestions, query expansion) are trimmed to fit instead. It also includes three bug fixes: bulk create no longer overwrites an existing entry, a type declared only in kb.yaml keeps its fields on create, and POST /api/index/sync?wait=true re-renders the site cache.
Fixed
-
Bulk create and
pyrite importno longer overwrite entries with duplicate IDs (#359). Existing entries and earlier entries in the same batch now fail individually with an “already exists” error; their files remain unchanged while valid siblings are imported. -
A kb.yaml-only type keeps every field it was given on create.
build_entryused to construct a type with no Python model class (declared only inkb.yaml) from justtags,summaryandmetadata, silently dropping every other field --pyrite create -f severity=high, MCPkb_create, and bulk/import all lost the value (#386). A field given this way is now honoured the same ascreated_at,importance,links,sourcesandidalready were for other entry types: it lands as a top-level frontmatter key (or, for a genuinely unrecognized key, is folded intometadataand promoted back to the top level on write) -- not left nested under ametadata:block. One consequence for callers who were silently succeeding: a select/enum field on such a type is now visible to schema validation, so a KB withvalidation.enforce: truerefuses an off-enum value it previously wrote anyway. That enforcement is only as strong as the surface that calls it:KBService.create_entry(CLIcreate -f, MCPkb_create) validates before writing, but bulk create andpyrite importstill skip validation entirely (tracked separately, #366/#381/#395) -- an off-enum value that used to be silently dropped on those two surfaces is now silently written instead, until that gap closes. -
POST /api/index/sync?wait=truenow actually re-renders the site cache, and the caller waits for it. The handler is a plain (sync) route with no running event loop, so the old code'sasyncio.get_running_loop()always raised, and a broadexcept Exceptionsilently swallowed it; a second bug hid behind it — the config it checked (request.app.state.config) was never set anywhere, so the render was skipped even when the loop lookup was patched around. Sincewait=truealready means the caller is blocked on the request, a sync that adds, updates or removes entries now includes a full site render as part of that wait — a real cost, paid once, instead of a silent no-op. The render's own outcome no longer affects the sync's own success (a render failure does not turn a committed sync into an error, and the embed-queue drain andkb_syncedbroadcast still happen either way):SyncResponsegains asite_cache: {rendered: bool, error: string|null}field so callers can tell the two apart. It isnullwhen the sync changed nothing (no render was attempted), andrendered: truemeans the render ran: an entry page that fails on its own is logged and skipped, asPOST /api/site/renderalready does.
Security
- Search queries have a maximum length. A query over 1,000 characters is refused with an explicit error on every surface (422
QUERY_TOO_LONGon REST, aQUERY_TOO_LONGvalidation error on MCP, a clear CLI error); it is never truncated.
Thanks to @AbelTomato for their contributions.
v0.25.2
Security release, the second batch from the multi-user threat-model pass begun in v0.25.1. It covers five areas: per-KB write checks, anonymous visitors' roles, a Host and Origin guard for credential-free servers, GitHub OAuth sign-in, and ephemeral KB privacy. Upgrade if you run Pyrite with anonymous_tier: write; without a credential (auth off and no API key) on a LAN or behind a reverse proxy; from the Render, Fly.io or Railway deploy templates; or with GitHub sign-in. A single-user install you reach only at localhost sees no change beyond the Host note below.
Before you upgrade, check these:
- Reaching a local, auth-off server by anything other than
localhost? That includes a LAN IP,*.local, or a proxy hostname. Add that name toallowed_hosts(PYRITE_ALLOWED_HOSTS) first, or requests get421. If the web UI is served from another origin, add it tocors_origins.localhost,127.0.0.1and[::1]work unchanged. The CLI, MCP over stdio and agents that send noHost/Originheaders are unaffected. - Deployed from a Render, Fly.io or Railway template? Those templates now start with auth enabled, and the first account registered becomes admin. Register your admin account immediately after the first deploy. Until an admin exists, anyone who reaches the URL first could claim that account. To stop open sign-up afterwards, set
PYRITE_AUTH_ALLOW_REGISTRATION=false. A later release replaces this with an admin created from the command line. If an existing deployment from these templates ran with auth off, enable auth and register now, or setPYRITE_ALLOWED_HOSTSto its public hostname. - Running
anonymous_tier: write? Review recent writes to KBs withdefault_role: noneorreadfor content you did not expect. - GitHub sign-in configured? A sign-in in progress at the moment of upgrade fails once; signing in again works.
/auth/github/statusshows the connected GitHub username. - Using ephemeral KBs? On upgrade, an ephemeral KB whose registry entry has no access policy is treated as private and named in a startup warning. Set
default_roleexplicitly if it should be shared.
The details, and the behaviour changes (a KB you cannot read answers 404 on every write route, anonymous_tier must be read, write or none, and more), are in the Security and Fixed sections below.
Added
-
CI's new advisory
verify-redjob reverts a pull request's implementation changes to the merge base and reports, test by test in the run summary, whether each changed test fails without the fix; a test that passes anyway gets a warning annotation (#352). -
Pyrite has a logo: a pyrite crystal read as a knowledge graph (an isometric cube inside a hexagonal frame, with nodes and edges along the crystal). It replaces the "Py" placeholder in the web app sidebar, landing page and empty overview, the favicon (a simplified cube that stays legible at 16 px), and heads the README. Light and dark variants follow the theme. The SVGs are generated from exact geometry by
docs/assets/logo/build_logo.py;pyrite-icon-512.pngis for avatars. -
pyrite task create --tags a,bsets tags on the new task, parsed likepyrite create --tags(comma-separated, whitespace stripped) (#308, thanks @ShivanshShukla). -
pyrite task list --priority Nfilters tasks by exact priority and combines with the existing status, assignee, parent, and KB filters (#309).
Changed
- The pre-push hook now runs
scripts/test-affected --run: a smallcoresmoke set plus every test that imports, directly or transitively, a module the branch changed, on 4 workers (PYRITE_PUSH_WORKERS). It falls back to the full suite when a branch touchesconftest.py, pytest or hook configuration, CI workflows or shared test fixtures;PYRITE_PUSH_FULL=1forces it. CI still runs the full suite on every pull request.scripts/test-affected --explainsays why each test was chosen (#356).
Fixed
-
MCP bulk create preserves valid entries when a sibling has no title. Missing or empty titles return per-entry failures in input order instead of rejecting the batch at parameter validation (#95).
-
Neighbor discovery now describes its search scope accurately (#65). With no target KB, results may include entries from the source KB; the source entry and, by default, existing links are still excluded.
JSON output from investigation commands, admin schema, and QA URL checks remains valid when piped, including long values and forced terminal colors. URL checks emit a JSON report for empty results without mixing in progress messages.
-
MCP
resources/readnow serves content instead of erroring on every call (#217). The SDK adapter was returning the wrong shape to the installedmcpSDK, so every read of apyrite://resource failed; the fix makespyrite://kbs,pyrite://kbs/{name}/entriesandpyrite://entries/{id}work over a real session, with the same private-KB scoping the rest of MCP already enforces. -
pyrite schema validate --changedno longer treats every changed Markdown file as a KB entry when no KB is configured (#346). With zero KBs registered, it now validates nothing instead of failing on ordinary repo files likeCHANGELOG.md,README.md, ordocs/**for lacking KB frontmatter. -
The Postgres backend works with SQLAlchemy 2.1. SQLAlchemy 2.1 made a bare
postgresql://URL use the psycopg (v3) driver, which thepostgresextra does not install, so building a Postgres engine failed withNo module named 'psycopg'. A barepostgresql://orpostgres://URL now always uses psycopg2; a URL that names its own driver (postgresql+psycopg://, ...) is used as given. -
Live updates now arrive for edits made through the REST API and for index jobs.
entry_created,entry_updated,entry_deletedandkb_syncedfrom the sync REST routes, andindex_progressfrom background index jobs, were silently dropped before reaching/ws; only web clips were delivered. Events are now handed to the server's event loop from any thread.index_progressis operator information: it reaches only unscoped sockets (an operator API key, or a server with auth off), whichever KB the job covers (#326, #322).
Security
-
Ephemeral KBs stay private across restarts. A user's ephemeral KB is private (
default_role: none: its creator and global admins only), and that policy is now written to the KB registry andconfig.yamlin the same step that creates the KB, so every process — a restarted server, a second worker, the CLI — applies it. Ephemeral KBs created by an admin orpyrite kb create --ephemeralare private by default too. The creator's grant and the KB are created together: if the grant cannot be recorded, the KB is removed. Operator note: ephemeral KBs created by earlier versions were readable (and writable, at write tier) by every signed-in user after any restart. On load, an ephemeral KB with nodefault_roleis now treated as private and a warning names it; this includes a KB registry entry under the workspace'sephemeral/directory whoseconfig.yamlentry is gone. -
POST /api/kbs/{kb}/pushand the MCPkb_pushtool accept only names.remotemust be the name of a remote configured in the KB's repository — a URL or a path is refused — andbranchmust be a plain branch name: a valid branch name that does not begin with-or+. The pushed refspec is built by the server (refs/heads/<branch>:refs/heads/<branch>), so a branch value never names a tag or requests a forced update.POST /api/kbs/{kb}/exportapplies the same branch rule. A rejected value answers400 INVALID_REFon REST and aVALIDATION_ERRORresult on MCP, and git is not run;pyrite kb pushrefuses it too. Every git call that takes a remote or branch value now passes it after--end-of-options(or validates it where git has no such marker), including the per-user worktree, merge and diff calls. -
Writes are checked against the KB they change, not only the caller's global role.
POST /api/clipnow requires write access on the target KB, andDELETE /api/reviews/{id}checks write access on the review's own KB. On every per-KB write route (entry create/update/patch/delete, review create and delete, collection create, task claim, daily-note create, clip), a KB the caller cannot read answers 404 exactly like a KB that does not exist, and a KB they can read but not write answers 403.requires_kb_tieron a route that names no KB is now a test failure. -
Anonymous visitors are scoped per KB on writes, as they already were on reads. With
anonymous_tier: write, an anonymous visitor could write to every KB, including private (default_role: none) and public read-only (default_role: read) ones. An anonymous visitor's role on a KB is now the lower ofanonymous_tierand that KB'sdefault_role, on reads and writes.anonymous_tieris a ceiling:default_role: writedoes not let aread-tier visitor write, anddefault_role: readornonestill refuses awrite-tier visitor.anonymous_tieris also validated when config loads:read,write, ornone(no anonymous access). Any other value, includingadmin, stops startup with an error. -
Starred entries are per user. Before this, any write-tier user could unstar or reorder everyone's stars. Schema migration v24 adds an owner to each star. On a multi-user instance, all existing stars become the instance's own list, which only auth-disabled and operator-API-key callers see; every signed-in user starts with an empty personal list. Starring an entry in a KB the caller cannot read answers 404, and anonymous visitors cannot star.
-
A server that accepts requests without a credential answers only the hostnames it expects and refuses cross-origin writes. This applies when auth is disabled and no API keys are configured, and when auth is enabled with
anonymous_tier: write. In those modes, requests must...
v0.25.1
Security release. It fixes authorization gaps in the multi-user path (auth enabled, several users): API-key handling, per-KB checks on export, MCP writes, settings, statistics and repositories, and path handling for entry ids, ephemeral KBs and exports. If you run Pyrite with auth enabled and more than one user, upgrade, and read the Security section for what to check on an existing install. Multi-user remains alpha; the 0.26 security review continues (see kb/roadmap.md).
Added
The MCP task_create tool now accepts an optional tags array and writes it through to the new task, matching the CLI and TaskService.create_task().
Fixed
- Concurrent first semantic/hybrid searches on a cold process no longer race to load the embedding model (#207).
EmbeddingService._get_model()checked the model cache under a lock but built the (slow, non-thread-safe)SentenceTransformermodel unlocked, so several concurrent first callers each built their own copy at once — a segfault or hang under real load. The model load now happens inside a double-checked lock: the first caller for a cold model name loads it once, every other concurrent caller waits and reuses the same object, and a failed load leaves the cache empty so the next caller can retry.
Security
-
With auth enabled and no API keys configured (the usual multi-user setup), any
X-API-KeyorBearervalue was accepted as an admin key on REST and/mcp, giving an anonymous caller read and write access to every KB, private ones included. A key is now accepted only when keys are configured; otherwise the request falls through to the session cookie or the anonymous tier. Installs with auth disabled are unchanged.
With auth enabled and keys configured, every/mcprequest that was not a valid key (a session cookie, a session-token Bearer, or a bad Bearer) failed with a 500; those now resolve the user or answer 401.
Operators: every release from v0.21.0 through v0.25.0 is affected. If you run a server with auth enabled, upgrade, and check your KBs' git history for entries you did not write. -
Expiring or removing an ephemeral KB deletes the per-KB permission grants recorded for it. Garbage collection and forced expiry removed the KB's index rows, files and config but left its
kb_permissionrows — including the admin grant its creator receives — so a KB later registered under the same name inherited them, and the former grantees could read, write or administer a KB nobody had granted them. -
An ephemeral KB's name can no longer point it at another KB's directory.
POST /api/kbs/ephemeral(open to write-role users) used the requested name as a directory under the workspace without validating it, so a name containing..or an absolute path created an "ephemeral" KB aliasing an existing directory, including a private KB's, with the caller as its admin. Names are now 1-64 letters, digits,-or_, starting with a letter or digit, and a name already used by another KB (in config or the KB registry), or whose directory already exists, is refused; both answer 400. A create never overwrites an existing KB registration. Expiring an ephemeral KB (garbage collection orDELETE /api/kbs/ephemeral/{name}) now never deletes a directory outside<workspace>/ephemeral/.
Operators: if you run a multi-user server, upgrade, then check your config for ephemeral KBs whosepathis outside<workspace>/ephemeral/and remove them; after upgrading they are unregistered on expiry without their directory being deleted. -
Export and site renderers no longer let a stored
entry_typeoridwrite outside the output directory, or let two distinct unsafe values collide onto the same output file (#221).pyrite/services/export_service.py,pyrite/renderers/quartz.py, andpyrite/renderers/notebooklm.pyeach joined a stored, caller-controlled value (an entry'stypeorid) directly into a filesystem path. An entry whosetypeoridwas an absolute path or contained../could makepyrite export(to a directory, to a repo,export site, orexport collection --bundle by-type) write attacker-controlled content outside the intended directory — reachable via two ordinary write-tier API calls (create an entry, then export) or an id indexed from git frontmatter that was never slugified. A safetypeorid(one that needed no sanitizing) still produces exactly the same file or folder name as before — but a value that needed sanitizing (e.g.note_,note/,a/b) is no longer merged onto the name of another value that sanitizes the same way (e.g.note,a_b); it now gets a short hash suffix so both survive.GenericEntryand plugin types are unaffected functionally, and the exported frontmatter'stype:still carries the raw value. -
Exporting a KB to a repository now requires being able to read that KB.
POST /api/kbs/{kb}/exportchecked only the caller's global write tier, so a write-role user with no access to a private KB could push that KB's entries into a repository of their choosing. The route now applies the same per-KB read rule as every read route, and a KB the caller cannot read answers exactly as one that does not exist (404). Admins, operator API keys and installs with auth disabled are unchanged.
Operators: if you run a multi-user server with private KBs (default_role: none), upgrade, and review your logs forPOST /api/kbs/*/exportcalls by users without access to that KB. -
An entry id could name a file outside its KB on lookup.
KBRepository.find_fileturned the id into<kb>/<id>.mdunvalidated, so the write-tier MCP toolkb_deletewithentry_id: "../x"deleted a.mdfile outside the KB, and an id such as*was treated as a glob and deleted whichever entry matched first. Writes were already guarded; lookups (and so delete, load and rename's source) no longer turn a non-plain id into a path, treat an id as a name rather than a glob pattern, and refuse any path that would land outside the KB (includingcollection-..). Ids from frontmatter that are not plain names are still found by the frontmatter scan, inside the KB.
Operators: anyone with write access over MCP could delete.mdfiles the server process can reach. Check for missing files outside your KB directories if you exposed/mcpwith write access. -
Deleting any KB, or unsubscribing a repository, deletes the per-KB permission grants recorded for its KBs. Removing a KB through the registry (
DELETE /api/kbs/{name}, the MCPkb_registry_removetool,pyrite kb remove) or unsubscribing a repository (DELETE /api/repos/{name}) removed the KB and its entries but left itskb_permissionrows behind, so a KB later registered under the same name inherited them and the former grantees could read, write or administer it. Grants are now deleted with the KB in the same transaction, on every deletion path. Removing a KB fromconfig.yamlalone does not delete it (it stays registered as a user-managed KB), so its grants are kept. -
The MCP
kb_statstool reports only the KBs the connection may read. Over/mcpit returned index-wide statistics to every caller — a private KB's name and row, and totals that counted its entries, tags, links and types — afterGET /api/statshad been scoped. It now applies the same scoping as the REST route: a scoped connection gets the per-KB map and every total from its readable KBs only; admins, operator API keys and local stdio are unchanged. -
MCP write tools now enforce the per-KB write permission REST enforces.
Over/mcp, a session user's global role chose which tools were offered,
but nothing checked their role on the KB a write tool targeted, so a user
whose effective role on a KB wasread(through itsdefault_roleor an
explicit grant) could still create, update and delete entries in it --
kb_create,kb_update,kb_delete, task tools and every write-tier
plugin tool. The same writes over REST were already refused. A scoped MCP
connection now resolves the KBs it may write through the same per-KB rule
REST uses, and every tool registered above the read tier is refused
(FORBIDDEN) unless each KB it names is writable. Operator API keys and
global admins are unaffected. Plugin read tools are now registered at the
read tier on write- and admin-tier servers, so they are rate-limited as
reads, and the journalism-investigation plugin'sinvestigation_search_all
andinvestigation_status, which only read, are now read-tier tools. Operators: if you run the HTTP MCP endpoint with users whose
per-KB role is narrower than their global role, review recent changes to
those KBs (git login each KB) for writes those users should not have
made. -
Operator settings are admin-only, and secret settings are never read
back. The settings API let any write-tier caller change instance-wide
operator settings -- the AI provider, model, base URL and API key -- and
returned stored credentials such asai.apiKeyin plain text to every
caller who could read settings, including the anonymous tier. Changing an
ai.*setting, or any setting whose name marks it as a credential
(apiKey,token,secret,password,credential), now requires the
admin tier;GET /api/settingsandGET /api/settings/{key}return a
fixed mask for a secret that is set (listed undermasked), to every
caller including admins, and writing the mask back leaves the stored value
unchanged. Credentials embedded inai.baseUrl(auser:password@
part, or a key or token query parameter) are masked for non-admins. The
web settings page shows the key as "set on the server", and a change the
server refuses no longer appears to stick.
Operators: if your instance allowed anonymous or write-tier access and
an AI API key was stored through the settings page, treat that key as
exposed and rotate it with your prov...
v0.25.0
The community's first release — see the announcement and kb/roadmap.md.
Changed
- Unreleased changes are now recorded as one file per change under
changelog.d/, not as a bullet inCHANGELOG.md(#243). Every
[Unreleased]bullet was appended at the same spot, so any two pull requests
in flight conflicted there by construction — five times in one session, three
of them on first-time contributors' branches, every one resolved by "keep
both, either order". A fragment is named<slug>.<section>.md, so no two
branches write the same path and neither a rebase nor a merge has anything to
resolve;scripts/release.pyassembles the fragments under the version
heading at release time and deletes them, and refuses an unknown section
rather than dropping the entry. Seechangelog.d/README.md.
Fixed
-
The cascade list tools return only rows from KBs the caller may read
(#223).cascade_actors,cascade_timeline,cascade_capture_lanes,
solidarity_timelineandsolidarity_infrastructure_typestakekb_name
optionally, and four of them default it to their own KB -- so a scoped caller
who omitted the name was refused rather than served a KB they may not read.
Each handler passes the caller's readable set to the storage query alongside
the name, which means the default KB is served only when it is readable, and
cascade_capture_lanes(which has no default) spans exactly what the caller
may read rather than the whole index. Unscoped callers are unchanged. -
A containerised Pyrite now binds
0.0.0.0and honours the platform's
$PORT, so a Docker or Railway deploy passes its healthcheck (#20). The
server defaulted to127.0.0.1:8088and ignoredPORT, so it came up on an
interface nothing outside the container could reach and the platform killed
it as unhealthy. Port precedence is nowPYRITE_PORT>PORT>
config/default, and a non-integer value raises an error naming the variable
it came from instead of a bareint()traceback. Only the image sets
PYRITE_HOST=0.0.0.0— a localpyrite servestill binds loopback, so
nothing on your machine starts listening on every interface because of this
change. Railway's one-click deploy still needs a volume mounted at/data. -
pyrite create -t <type>no longer silently files a different type when the
KB does not declare the one asked for (#197). Core types were exempt from
the CLI's write-side refusal, so-t noteagainst a KB whose schema declares
onlyadr | backlog_item | component | standardskipped the guard, and plugin
type resolution then promoted it to its most-derivednotesubtype — an ADR
withadr_number: 0underkb/adrs/, from a command that asked for a note.
The refusal now covers every type the KB does not declare;--allow-undeclared
still overrides it. -
The four protocol finders narrow to the caller's readable KBs in SQL
(#223).find_by_assignee,find_overdue,find_by_statusand
find_by_locationtook a singlekb_name, so the fourkb_find_by_*MCP
tools cut the page withLIMITand dropped unreadable rows afterwards -- a
scoped caller could receive a short page while readable rows existed below
the cut. Each finder now takeskb_namesand narrows through the shared
kb_names_clause(a named KB binds, a readable set narrows, an empty set
matches nothing,Noneis unchanged), and the four handlers pass the
readable set down. The handler's post-filter stays as the fail-closed
guard. -
A
GenericEntryno longer duplicates its undeclared frontmatter keys into
ametadata:block on save.Entry._base_frontmatterserialized the whole
self.metadatamapping as a nested block whileGenericEntry.to_frontmatter
also promoted the same keys to top level, so a no-op load→save grew a
metadata:block the source file never had. Only keys that came from an
explicitmetadata:block stay nested now; the rest are promoted once
(#149). Ametadata:value that is not a mapping (null, a string, a list, a
number) is kept verbatim and written back on the next save, with a warning,
instead of failing the load and saving the file back as a different entry
type. Deliberate behaviour change: an entry created withmetadata={…}now
writes those keys top-level only, wheredevalso wrote a nested block --
except a key the base frontmatter already emits (title,id, …), which
stays nested undermetadata:rather than being dropped. -
pyrite index healthno longer reports asubdirectory_mismatchesfalse
positive for every entry of a type whose declared subdirectory ends in/.
people/andpeopleare the same directory, but the check compared the
declared string against a path component, so a KB created exactly as the
getting-started guide instructs came backstatus: warningwith one row per
entry. Both sides are normalized now, and an entry genuinely in the wrong
directory is still flagged (#44). -
investigation_search_allandinvestigation_find_duplicatessearch only
the KBs the caller may read (#223). Both take a list of KB names, and
omitting it means "every KB" to the code they delegate to -- so a scoped
caller who omitted it searched the whole index. Each handler now composes
what was asked for with what may be read: a list that names KBs keeps the
readable ones among them, no list means every readable KB, and an empty
result stays an empty list rather than collapsing back to "every KB".
Unscoped callers are unchanged. These were the two genuinely cross-KB tools
in the last extension; the rest resolve to a single KB and are handled
separately. -
The single-KB investigation tools serve nothing when the caller may not
read the KB they resolve to (#223). Twelve read tools --investigation_timeline,
investigation_entities,investigation_network,investigation_sources,
investigation_claims,investigation_evidence_chain,
investigation_export_pack,investigation_money_flow,
investigation_qa_report,investigation_ownership_chain,
investigation_ftm_export,investigation_status-- settle on one KB when
none is named: the caller's, or the plugin's own default. A scoped caller was
refused those calls rather than served a KB they may not read. Each resolves
through a guard now, which answers with a KB name that matches no entry when
the resolved KB is not readable, so the tool keeps its normal shape with
nothing in it. The write-path tools in the same extension keep the
fail-closed listing, because a caller who may write a KB can read it. -
Six backlog files no longer carry a folded copy of their own body, or
another machine's absolute path (#150). They were committed with abody:
frontmatter key holding the whole entry text as a YAML scalar, and a stray
file_path:key pointing at a worktree on the machine that wrote them. The
write path already drops both keys on any save, so every load-and-save
rewrote those files; they have now been re-saved once, deliberately. The
text is unchanged -- the body section was already the whole entry, and for
two of them the folded copy was a truncated prefix of it -- and the
round-trip gate's residual count drops from 69 to 63. -
cascade_networkandinvestigation_networkpage both directions instead of
returning every neighbour at once (#63). On a hub node the old response was
roughly 18k tokens (35 outlinks plus about 130 backlinks for one cascade
node). Both tools now takelimit(default 50,0means no cap) andoffset
per direction, order each direction deterministically so a page never repeats
or skips a row, and return the true totals plustruncatedso a caller can
tell what it did not see. The paging lives inquery_network;
get_outlinksstill has no pagination parameters of its own. -
The API returned 500s under ordinary concurrent read load: one SQLAlchemy
Sessionwas shared by every request.PyriteDBcreated a single
Sessionin its constructor and the server cached onePyriteDBon app
state, so the ~117 plaindefhandlers — which FastAPI runs on anyio's
40-thread worker pool — all drove one session at once, with
check_same_thread=Falsesilencing SQLite's own guard. The result was
corrupted result state rather than a clean error, surfacing as
IndexError: tuple index out of range,InvalidRequestError: This session is provisioning a new connection,SystemError, and in CIThis session is in 'prepared' state— four messages, one cause. Measured at 75% of 240
concurrent reads failing in-process and 9 of 96 requests returning 500
at concurrency 8 against a live server; both are now 0. Each request gets
its own session via a per-request handle onto the shared engine, closed on
every exit path, and the connection pool is sized to the threadpool
(pool_size=40, max_overflow=20) so per-request sessions cannot trade the
corruption forQueuePool limit ... reached.verify_api_keyno longer runs
synchronous DB work on the event-loop thread, and the shared raw sqlite3
connection now hands out a private cursor under a lock instead of sharing an
implicit one. Query results and every endpoint's behaviour are unchanged.
Fixes #131. -
Postgres:
ensure_schema()now creates the full-text-search trigger in every schema it sets up. Its guard asked whether a trigger namedtrg_entry_ftsexisted anywhere in the cluster, so a second Pyrite instance sharing one database in its own schema silently got no trigger —entry.fts_vectorwas never populated and keyword search returned no results, with no error. -
A
QUERY_SYNTAXerror names the token the caller wrote instead of leading
with SQLite's fragment (#67).detention AND third-party-doctrinereaches
MATCH unquoted because the query carries an operator, and SQLite answer...