Skip to content

Releases: pyrite-wiki/pyrite

v0.26.0

Choose a tag to compare

@markramm markramm released this 23 Oct 03:50
Immutable release. Only release title and notes can be modified.
adc9657

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=alpha and --unset extra; MCP kb_update with append, remove ({field: item}; a list value means each item) and unset (a list of keys); REST PATCH /api/entries/{id} with op: append | remove | unset beside the default set. 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). links and sources take item operations on every surface, with an appended link checked as a link; a set of the whole list is still refused. A null value is written as null; removal is unset. 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 to ka:x (backlinks x -k kb was empty),
    a second prefix of one target ([[ops:x]] [[ops-kb:x]]) was never checked
    by index health, and a body [[x]] beside a frontmatter link to x was
    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, get outlinks, the graph, links check and the
    broken_links count, and ![[x]] is now stored as a transclusion rather
    than a wikilink. Rebuild the index (pyrite index build) to see the new
    rows for files that have not changed.
    The web backlinks panel and the backlinks table 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 (2024 and '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 written prefix so 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, dotted metadata.k, under metadata; an alias of a
    managed field is folded too, though none is registered today) on every
    create path: MCP kb_create/kb_bulk_create, CLI create -f, task create
    and the in-process create_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-adr names adr_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 under ignored for its entry,
    so an export imports into another KB and nothing is dropped silently
    (#1091; a create stamps new timestamps). The Ignored: label prints without
    Rich markup tags shown as literal text, and the key list is escaped as data
    (#1096).
    pyrite add FILE still adopts a hand-written file's own values. REST POST /api/entries refuses only the
    metadata spelling: 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 bare bar,
    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.md no longer says an update re-renders the frontmatter: it names the two examples that still fail, shows what a create writes, and gives the set/append/remove/unset spelling per surface. docs/agent-write-path.md says an undeclared field is written at the top level of the file and has a runnable step for --append, --remove and --unset. MCP kb_update's tags and metadata descriptions now say that append/remove add or drop one tag and that a key in metadata is the field of that name (ADR-0051).

  • Storage performance: list_entries batch-fetches tags in a single statement using _get_tags_for_entries instead 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-red stays advisory (it is not in gate and 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_graph changes what it returns (#523). depth is capped at 3:
    1-3 are 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 under neighbors now carries id, kb_name and the
    hop depth it was found at, and is keyed by the bare entry id when it is in
    the KB you asked about and by kb:id when it lives in another KB. Callers
    that looked up neighbours by id alone must use the kb:id key 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...

Read more

v0.25.8

Choose a tag to compare

@markramm markramm released this 16 Oct 19:36
Immutable release. Only release title and notes can be modified.
cea5efc

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 collection and pyrite export site name the destination with --target (#873). --format is the shared output-format option of the outcome contra...
Read more

v0.25.7

Choose a tag to compare

@markramm markramm released this 13 Oct 18:42
Immutable release. Only release title and notes can be modified.
1401b45

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.md says 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 real kb_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 no id: 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-run prints 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 missing and --dry-run write 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 text for reading); both exit 0 when nothing is left, 3 when files remain outside the id contract, 1 on 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.json runs scripts/cloud-session-start.sh (venv, extensions, repo-local KB config, index, git hooks). scripts/setup-checkout.sh is the one install path it shares with scripts/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 same pgvector/pgvector:pg16 container CI uses (PYRITE_SETUP_POSTGRES=0 opts out).

  • "Pyrite in 20 minutes", a tutorial that is its own acceptance test. docs/tutorials/pyrite-in-20-minutes.md takes 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 with scripts/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_server and pyrite-mcp serve now default to the write tier, like pyrite mcp (ADR-0006). pyrite-admin mcp was admin and the other two were read. pyrite-admin mcp also rejects an unknown tier with INVALID_TIER. A hand-written MCP config that runs pyrite-admin mcp with no --tier and relied on the admin tools (kb_push, kb_registry_*, kb_commit, ...) must add "--tier", "admin" to its args. A config that runs pyrite-mcp serve or python -m pyrite.server.mcp_server with no --tier was 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-setup is removed (it now exits 1 and points to pyrite mcp-setup): it ignored --tier and wrote three servers (pyrite-read, pyrite-write, pyrite-admin) to a file no client reads. Use pyrite 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-admin and
    pyrite-read now log at WARNING by default through one shared function, so
    init, create, search and index embed no longer print one
    Applied migration line per migration, a search.query line per search or
    the sqlite-vec line on stderr; stdio pyrite mcp stops 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, and PYRITE_LOG_LEVEL sets the
    level without a flag. pyrite serve and pyrite-server keep INFO as the
    operator's log. pyrite-admin, pyrite-read and pyrite-server now print
    warnings, formatted like pyrite's; before, the package's inert handler
    swallowed them. (#584)

  • pyrite search --mode semantic|hybrid without the [semantic] extra (or
    without the sqlite-vec extension) now returns a warnings entry 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 embed counts the entries it settled from the embed queue as
    embedded instead of skipped: after writes it printed Embedded: 0, Skipped: 4
    for a run that added four vectors. (#584)

  • A -v that is an option's value (pyrite create -b -v) stays the value, as
    before; it is not read as the verbosity flag. pyrite search --debug now
    reports the same skip cause as the warning (reason=semantic_extra_missing,
    and actual=none for a semantic search that did not run). pyrite index embed --kb A says 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_LEVEL and -v are in
    docs/configuration.md. (#584)

  • kb_orient / pyrite orient is 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 answers KB_NOT_FOUND over MCP, the
      code REST and the CLI already used (ADR-0037). Two older codes are
      replaced: the NOT_FOUND every MCP tool, prompt and resource gave a
      caller with per-KB scoping for a KB outside its readable set, ...
Read more

v0.25.6

Choose a tag to compare

@markramm markramm released this 01 Oct 21:23
Immutable release. Only release title and notes can be modified.
4dadcc6

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 validate reports a file that uses a Pyrite-reserved key for a field of its own (#557): rule reserved_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.yaml are now enforced: a write with an off-list value is refused by default (#555). Before, a values: list was silently ignored, validation.rules entries with enum: were never evaluated, and list fields were never checked, so validators reported clean over real drift. Now options: (canonical) and its alias values: on select, multi-select and list fields, list items: {options: [...]}, and rule-level enum: are all checked, the same way by writes, qa validate, ci, schema validate (the pre-commit hook) and index health. A new switch, validation.enforce_enums (default true), 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 of validation.enforce, which still governs every other check. allow_other: true still 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 kind too. Changing the field to another off-list value, or adding an off-list list element, is still refused, and create is always strict. pyrite update now prints the write's warnings in its JSON, as REST and MCP already did.

  • pyrite index health reports 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 status unhealthy (exit 1) when enforce_enums is on and warning when it is off. Rows from a plugin validator's enum are always warning; rows for an allow_other field are info and leave the status unchanged.

  • Shipped schemas no longer constrain one type's field with a KB-wide rule. A validation.rules enum applies to every entry with the field. docs/desk-schema.yaml's status rule now lists every task workflow state (claimed, review, failed and cancelled were missing, so claiming a desk task would have been refused); the software init template and the software-kb preset no longer carry an ADR-only status rule (the software-kb validator checks each type's status for a KB with kb_type: software; a KB made by pyrite init -t software is registered as generic and has no status check until #572); the movement template's status enum is now a field enum on practice, so events are not refused. pyrite task create and pyrite import now print the write's schema warnings, as create and update do. schema validate warns when validation.enforce_enums is not a boolean (only false turns enforcement off).

  • Upgrade note. A KB that relied on the old behaviour, with data that drifts from its declared enums (including values: lists and rule enum:s that used to be ignored), now refuses off-list writes, and index health and ci report it as failing. To migrate: set validation.enforce_enums: false in kb.yaml, clean up guided by pyrite qa validate and pyrite index health (off_list_values), then remove the line. A KB created from an older template keeps that template's rules in its own kb.yaml: if it was made with pyrite init -t software (or the software-kb preset), delete the status rule under validation.rules (it lists only ADR statuses and now refuses backlog, design-doc and milestone statuses); with -t movement, move the status rule to types.practice.fields.status: {type: select, options: [active, deprecated, evolved]}; a desk made from docs/desk-schema.yaml, replace its status rule with the current one.

Fixed

  • MCP kb_update stores keys the entry type does not declare, the way pyrite update -f and REST PATCH do (#455); it used to drop them and report updated: true. Keys a kb_get result carries that no update writes (the id, path, timestamps, managed fields, index columns, and nulls for fields the entry does not use) are not written and are listed in the result's new ignored field. A null for a key the type does not declare is also set aside, so kb_update can set such a key but not clear it.

  • pyrite index reconcile no longer proposes moving every file whose type sets a file_pattern (#474). It now moves only entries whose type has a templated subdirectory (such as backlog/{status}), keeps each file's name, and leaves static placements and __collection.yaml files 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 get and pyrite search reject unsupported --format values during argument parsing with exit code 2.

  • One task with a word priority no longer fails every task listing (#554). pyrite task list and every cross-KB task listing ended with invalid literal for int() with base 10: 'medium' when any task in any KB had priority: medium. A task's priority is now read on its 1-10 scale wherever it is read: low/medium/high/critical as 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 validate reports a task whose priority is not an integer from 1 to 10 (rule task_priority, naming the file), and its schema check now reads the fields a core type keeps outside metadata (a task's priority, assignee, due_date...) from the file as written, which it used to report as missing; a kb.yaml that retypes a task's priority (a select of words, say) is reported once, naming the file and the field (rule schema_retypes_core_field). An existing index keeps the old value of an unchanged file, because pyrite index sync re-reads only files whose content changed: run pyrite index build (or pyrite 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, a links: [a, b] shorthand -- used to be dropped or rewritten by every pyrite update, REST PATCH/PUT, MCP kb_update, task update or link on 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 as provenance is still refused, before anything is written, and the message now says to rename the file's key.

  • Sending a kb_get result back to MCP kb_update changes only what you changed (#561). A field still at the value the read returned is not written and is listed in the result's new unchanged field (apart from ignored, which names keys no update writes), so importance: high is no longer rewritten as 5 and a task no longer grows importance: 5 and priority: '5'. Updating an event's status (echoed or set) no longer fails with 'str' object has no attribute 'value'; an unknown status is refused with the allowed values. tags: Foo (and aliases: Foo) is read as one tag instead of its characters, and a bare URL in sources: 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, because pyrite index sync re-reads only changed files: run pyrite index build (or pyrite 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=yes used to write decision: yes bare; a reader implementing YAML 1.1 (PyYAML's safe_load, the most common Python YAML library) resolves that to the boolean true, 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/off in any case, true/false in any case, ~/null, and 1.1-only numbers such as sexagesimal 1: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/N are left unquoted...
Read more

v0.25.5

Choose a tag to compare

@markramm markramm released this 27 Sep 00:49
Immutable release. Only release title and notes can be modified.
dee3180

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.132 and mcp>=1.27.2,<2. With an older MCP library, /mcp is 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. Read body["detail"]["code"].
  • Cookie-authenticated writes need an allowed Origin. Behind a reverse proxy that rewrites Host, add the public origin to cors_origins (PYRITE_CORS_ORIGINS).
  • Access changes on hand-written config KBs: changing the default access of a KB defined in a hand-written config.yaml now 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_outlinks and related calls) now require readable_kbs=. Pass the caller's readable set, or UNSCOPED (from pyrite.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-a and --kb-b also returns each link once; two-KB checks are unchanged (#465).

Changed

  • pyrite rename X X (and KBRepository.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 PyriteError that reaches the central handler now answers
    in the same {"detail": {"code", "message", "retryable", "hint"?}} shape
    every HTTPException(detail={...}) site already used. Before this
    release the central handler answered a flat {"code", "message"} body
    ,
    so a client reading body["code"] from those responses must now read
    body["detail"]["code"]. Status codes are unchanged. Most responses are
    unaffected: 91 of 124 HTTPException sites already answer their own
    code in the detail shape and are untouched. The code string changes
    for these classes when the central handler answers them:

    • UndeclaredTypeError, EntryExistsError, SchemaViolationError and
      InvalidGitRefError now answer their own codes (UNDECLARED_TYPE,
      ENTRY_EXISTS, SCHEMA_VIOLATION, INVALID_REF) where the central
      handler previously answered the generic VALIDATION_ERROR for all
      four (their status codes are unchanged).
    • ClipperBlockedHostError now answers CLIPPER_BLOCKED_HOST where the
      central handler previously answered INTERNAL_ERROR (both 500).
    • A bare ValidationError (and any subclass without its own code, e.g.
      TruncatedBodyError) answered by the central handler now answers
      VALIDATION_FAILED where it previously answered VALIDATION_ERROR.
      VALIDATION_FAILED is 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_FOUND vs
    ENTRY_NOT_FOUND/KB_NOT_FOUND, READ_ONLY vs KB_READ_ONLY,
    CONFIG_ERROR vs CONFIG_CONFLICT/CONFIG_SAVE_REFUSED,
    REQUEST_REFUSED vs STORAGE_ERROR/PLUGIN_ERROR, and
    VALIDATION_FAILED vs INVALID_FRONTMATTER for FrontmatterError
    specifically), MCP now reports REST's code and carries its own previous
    code for one release in a new legacy_error_code field, so an existing
    MCP caller matching on the old string keeps working through the
    transition. The next release removes legacy_error_code and 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_FAILED fallback, now emit the exception class's own code the
    same way — for the common case (a bare ValidationError, e.g. a missing
    title) that code is VALIDATION_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 generic ERROR
    (#481). The extension CLI create commands (zettelkasten,
    software-kb) do change: they read the exception's error_code and fell
    back to CREATE_FAILED, and since every PyriteError now carries a code
    (the base's is INTERNAL_ERROR), a failed create there now reports the
    exception's own code instead of CREATE_FAILED.

    Server log. The central handler's Python logger name changed from
    pyrite.server.api to pyrite.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, PluginError and ConfigError now have a fixed,
    safe public_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 own public_message
    (ConfigSaveRefusedError/ConfigFileUnreadableError, #377) are
    unaffected. The fixed message replaces the raw text wherever these
    classes reach a caller:

    • MCP: the error of kb_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_push and kb_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 job error field (admin GET /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 new and sw new-adr
      print str(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: true unconditionally on every create, clip or import.
    GET /api/entries/type-schemas reports the KB's declared types as an
    additive declared field. (#392)

Fixed

  • Extension writes go through the write pipeline (#391). MCP social_post and pyrite zettel new used to write entries directly via KBRepository.save, bypassing KBService'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 separate pyrite index sync. Both now go through KBService.create_entry, so an existing id is refused with ENTRY_EXISTS (the file is left byte-identical), hooks run, and the new entry is searchable immediately.

  • sw new-adr goes through the write pipeline, and file_pattern gains entry-field placeholders (#391). sw new-adr used to write the ADR file directly, bypassing KBService: 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 separate pyrite index sync, and a KB that failed to resolve silently fell back to writing ./adrs under the current directory. It now creates through KBService.create_entry: an existing id, or a different id that would resolve to the same on-disk path (e.g. two ADRs given the same adr_number, or two without one at all), is refused with ENTRY_EXISTS and the existing file is left byte-identical; no KB resolved refuses with KB_NOT_FOUND instead 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...

Read more

v0.25.4

Choose a tag to compare

@markramm markramm released this 25 Sep 22:59
Immutable release. Only release title and notes can be modified.
7c1a846

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 stable error_code everywhere — UNDECLARED_TYPE, ENTRY_EXISTS, SCHEMA_VIOLATION or VALIDATION_FAILED — instead of a per-surface CREATE_FAILED/UPDATE_FAILED; REST answers a duplicate id with 409. See docs/json-contracts.md, "Write refusals".

  • MCP kb_create no longer exempts core types from the undeclared-type refusal, matching the CLI (#197): in a KB whose kb.yaml declares types, entry_type: note is refused with UNDECLARED_TYPE unless the type is declared or allow_undeclared: true is passed. REST POST /api/entries and /api/entries/import, pyrite add and pyrite import apply the same refusal, with an allow_undeclared / --allow-undeclared override.

  • REST create and update return warnings. POST, PUT and PATCH /api/entries responses carry a warnings list with the schema's non-blocking findings, as MCP already did.

  • pyrite import exits 1 when any record is refused, prints each refusal with its code, and --dry-run now 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 in kb.yaml are kept and validated too.

  • MCP kb_update takes its field set from the entry's type (its model and its kb.yaml fields) instead of a fixed core list; a kb.yaml-declared field of a custom type is now written instead of silently ignored, and id, path and timestamps are never rewritten.

  • Updates refuse fields Pyrite maintains. REST PUT/PATCH /api/entries/{id} and pyrite update --field refuse id, file_path, kb_name, links, sources and provenance with VALIDATION_FAILED (setting id used to leave a second file), and so do a task's audit fields (status_change_log, evidence, agent_context, assigned_at) and an ADR's adr_number; MCP kb_update ignores them. Entry types, including plugin types, declare such fields with managed_fields.

  • The web app keeps its type behaviour. The web client sends allow_undeclared: true on create, clip and import, so the New-entry form, which offers every core and plugin type, still works in a KB whose kb.yaml declares types. POST /api/clip now takes allow_undeclared like the other create endpoints and reports the pipeline's codes (ENTRY_EXISTS is a 409).

  • One plugin validator and hook contract, checked once at registration. PluginRegistry now checks every plugin's get_validators() and get_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 a TypeError swallowed 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 accept ctx as a third positional argument. A validator that binds the contract but still returns the old list[str] shape (instead of list[dict]) has its non-dict items dropped with a warning rather than crashing a caller. A before_* 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 dropped after_* hook only logs a warning, since the operation has already succeeded. HookRunner now owns both the core-hook and plugin-hook phases under one raise-before/swallow-after contract.

  • The cascade and journalism-investigation extensions' validators were on the old, non-conforming signature: their validation was silently disabled since the 3-argument contract was introduced, so a cascade-research or journalism-investigation/known-entities KB now refuses writes it previously accepted silently — an actor with no title, an asset with no asset_type, an out-of-range importance, 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. Run pyrite qa validate -k <kb> (or pyrite index health, which reports the same drift for status fields) to find entries that would now fail validation before they're next edited. See kb/standards/extension-development.md, "Validator signature".

  • investigation promote-claim (CLI) and investigation_promote_claim (MCP) accept the promoted edge's endpoint fields. Promoting a claim to ownership/funding/membership never 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/--organization CLI options and an endpoint_fields MCP argument supply them; a promotion missing the required pair is refused with a clear message (not a raw SchemaViolationError), and --dry-run/dry_run now 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 returns retryable: true, so an agent may retry it. Every other storage fault (schema drift, a missing table, a corrupt file) stays retryable: false. The error_code is unchanged (REQUEST_REFUSED), and MCP now logs each storage fault with its traceback. SearchService.search takes an optional semantic_query, the text the semantic leg embeds when it should differ from the keyword query.

  • Lowercase and/or/not no 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 in title:foo and bar or foo* or bar the column filter and the prefix match become literal text. Write title:foo AND bar or foo* OR bar to keep them as FTS5 syntax.

  • scripts/test-affected --run and 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 prints already passed on tree <sha> (...); skipping and exits 0. A dirty tree, a failure and a partial run are never stamped. --force or PYRITE_PUSH_FORCE=1 runs anyway. After a load-caused failure, scripts/test-affected --run -- --lf re-runs only the failures. CI's push to dev likewise skips the test matrix and the frontend job when the merge queue already passed that exact commit; the smoke layer still runs.

  • verify-red no 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.control marks a deliberate negative control. CI also reports advisory diff coverage (80% of changed lines) and keeps both in a test-evidence artifact (#368).

Removed

  • PluginRegistry.run_hooks and PluginRegistry.run_hooks_for_kb are gone. Both ran plugin hooks directly; that responsibility moved to HookRunner, which now owns the raise-before/swallow-after contract for both core and plugin hooks in one place. PluginRegistry.get_hooks_for_kb remains, 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 a HookRunner(plugin_registry=...) and call run_before_save/run_after_save/run_before_delete/run_after_delete instead.

Fixed

  • A second relation between the same two entries is now recorded instead of silently dropped (#396). pyrite link a b -r implements followed by pyrite link a b -r supersedes used to report Linked: for the second call too but write nothing — the duplicate check ignored relation. The duplicate key is now (target, kb, relation), normalised so a link written before this fix (or by hand, with no relation: key at all) still matches the CLI/MCP default relation rather than looking like a second, dist...
Read more

v0.25.3

Choose a tag to compare

@markramm markramm released this 25 Sep 07:45
Immutable release. Only release title and notes can be modified.
09f759b

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 import no 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_entry used to construct a type with no Python model class (declared only in kb.yaml) from just tags, summary and metadata, silently dropping every other field -- pyrite create -f severity=high, MCP kb_create, and bulk/import all lost the value (#386). A field given this way is now honoured the same as created_at, importance, links, sources and id already were for other entry types: it lands as a top-level frontmatter key (or, for a genuinely unrecognized key, is folded into metadata and promoted back to the top level on write) -- not left nested under a metadata: 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 with validation.enforce: true refuses an off-enum value it previously wrote anyway. That enforcement is only as strong as the surface that calls it: KBService.create_entry (CLI create -f, MCP kb_create) validates before writing, but bulk create and pyrite import still 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=true now 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's asyncio.get_running_loop() always raised, and a broad except Exception silently 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. Since wait=true already 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 and kb_synced broadcast still happen either way): SyncResponse gains a site_cache: {rendered: bool, error: string|null} field so callers can tell the two apart. It is null when the sync changed nothing (no render was attempted), and rendered: true means the render ran: an entry page that fails on its own is logged and skipped, as POST /api/site/render already 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_LONG on REST, a QUERY_TOO_LONG validation error on MCP, a clear CLI error); it is never truncated.

Thanks to @AbelTomato for their contributions.

v0.25.2

Choose a tag to compare

@markramm markramm released this 25 Sep 05:19
Immutable release. Only release title and notes can be modified.
6b4158a

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 to allowed_hosts (PYRITE_ALLOWED_HOSTS) first, or requests get 421. If the web UI is served from another origin, add it to cors_origins. localhost, 127.0.0.1 and [::1] work unchanged. The CLI, MCP over stdio and agents that send no Host/Origin headers 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 set PYRITE_ALLOWED_HOSTS to its public hostname.
  • Running anonymous_tier: write? Review recent writes to KBs with default_role: none or read for 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/status shows 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_role explicitly 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-red job 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.png is for avatars.

  • pyrite task create --tags a,b sets tags on the new task, parsed like pyrite create --tags (comma-separated, whitespace stripped) (#308, thanks @ShivanshShukla).

  • pyrite task list --priority N filters 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 small core smoke 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 touches conftest.py, pytest or hook configuration, CI workflows or shared test fixtures; PYRITE_PUSH_FULL=1 forces it. CI still runs the full suite on every pull request. scripts/test-affected --explain says 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/read now serves content instead of erroring on every call (#217). The SDK adapter was returning the wrong shape to the installed mcp SDK, so every read of a pyrite:// resource failed; the fix makes pyrite://kbs, pyrite://kbs/{name}/entries and pyrite://entries/{id} work over a real session, with the same private-KB scoping the rest of MCP already enforces.

  • pyrite schema validate --changed no 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 like CHANGELOG.md, README.md, or docs/** 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 the postgres extra does not install, so building a Postgres engine failed with No module named 'psycopg'. A bare postgresql:// or postgres:// 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_deleted and kb_synced from the sync REST routes, and index_progress from 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_progress is 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 and config.yaml in 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 or pyrite kb create --ephemeral are 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 no default_role is now treated as private and a warning names it; this includes a KB registry entry under the workspace's ephemeral/ directory whose config.yaml entry is gone.

  • POST /api/kbs/{kb}/push and the MCP kb_push tool accept only names. remote must be the name of a remote configured in the KB's repository — a URL or a path is refused — and branch must 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}/export applies the same branch rule. A rejected value answers 400 INVALID_REF on REST and a VALIDATION_ERROR result on MCP, and git is not run; pyrite kb push refuses 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/clip now requires write access on the target KB, and DELETE /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_tier on 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 of anonymous_tier and that KB's default_role, on reads and writes. anonymous_tier is a ceiling: default_role: write does not let a read-tier visitor write, and default_role: read or none still refuses a write-tier visitor. anonymous_tier is also validated when config loads: read, write, or none (no anonymous access). Any other value, including admin, 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...

Read more

v0.25.1

Choose a tag to compare

@markramm markramm released this 23 Sep 22:50
Immutable release. Only release title and notes can be modified.
cacd29b

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) SentenceTransformer model 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-Key or Bearer value 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 /mcp request 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_permission rows — 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 or DELETE /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 whose path is 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_type or id write 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, and pyrite/renderers/notebooklm.py each joined a stored, caller-controlled value (an entry's type or id) directly into a filesystem path. An entry whose type or id was an absolute path or contained ../ could make pyrite export (to a directory, to a repo, export site, or export 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 safe type or id (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. GenericEntry and plugin types are unaffected functionally, and the exported frontmatter's type: still carries the raw value.

  • Exporting a KB to a repository now requires being able to read that KB. POST /api/kbs/{kb}/export checked 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 for POST /api/kbs/*/export calls by users without access to that KB.

  • An entry id could name a file outside its KB on lookup. KBRepository.find_file turned the id into <kb>/<id>.md unvalidated, so the write-tier MCP tool kb_delete with entry_id: "../x" deleted a .md file 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 (including collection-..). 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 .md files the server process can reach. Check for missing files outside your KB directories if you exposed /mcp with 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 MCP kb_registry_remove tool, pyrite kb remove) or unsubscribing a repository (DELETE /api/repos/{name}) removed the KB and its entries but left its kb_permission rows 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 from config.yaml alone does not delete it (it stays registered as a user-managed KB), so its grants are kept.

  • The MCP kb_stats tool reports only the KBs the connection may read. Over /mcp it returned index-wide statistics to every caller — a private KB's name and row, and totals that counted its entries, tags, links and types — after GET /api/stats had 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 was read (through its default_role or 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's investigation_search_all
    and investigation_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 log in 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 as ai.apiKey in 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/settings and GET /api/settings/{key} return a
    fixed mask for a secret that is set (listed under masked), to every
    caller including admins, and writing the mask back leaves the stored value
    unchanged. Credentials embedded in ai.baseUrl (a user: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...

Read more

v0.25.0

Choose a tag to compare

@markramm markramm released this 23 Sep 01:34
Immutable release. Only release title and notes can be modified.
d2f32c6

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 in CHANGELOG.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.py assembles the fragments under the version
    heading at release time and deletes them, and refuses an unknown section
    rather than dropping the entry. See changelog.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_timeline and solidarity_infrastructure_types take kb_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.0 and honours the platform's
    $PORT, so a Docker or Railway deploy passes its healthcheck (#20).
    The
    server defaulted to 127.0.0.1:8088 and ignored PORT, so it came up on an
    interface nothing outside the container could reach and the platform killed
    it as unhealthy. Port precedence is now PYRITE_PORT > PORT >
    config/default, and a non-integer value raises an error naming the variable
    it came from instead of a bare int() traceback. Only the image sets
    PYRITE_HOST=0.0.0.0 — a local pyrite serve still 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 note against a KB whose schema declares
    only adr | backlog_item | component | standard skipped the guard, and plugin
    type resolution then promoted it to its most-derived note subtype — an ADR
    with adr_number: 0 under kb/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_status and
    find_by_location took a single kb_name, so the four kb_find_by_* MCP
    tools cut the page with LIMIT and dropped unreadable rows afterwards -- a
    scoped caller could receive a short page while readable rows existed below
    the cut. Each finder now takes kb_names and narrows through the shared
    kb_names_clause (a named KB binds, a readable set narrows, an empty set
    matches nothing, None is unchanged), and the four handlers pass the
    readable set down. The handler's post-filter stays as the fail-closed
    guard.

  • A GenericEntry no longer duplicates its undeclared frontmatter keys into
    a metadata: block on save.
    Entry._base_frontmatter serialized the whole
    self.metadata mapping as a nested block while GenericEntry.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
    explicit metadata: block stay nested now; the rest are promoted once
    (#149). A metadata: 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 with metadata={…} now
    writes those keys top-level only, where dev also wrote a nested block --
    except a key the base frontmatter already emits (title, id, …), which
    stays nested under metadata: rather than being dropped.

  • pyrite index health no longer reports a subdirectory_mismatches false
    positive for every entry of a type whose declared subdirectory ends in /.

    people/ and people are 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 back status: warning with one row per
    entry. Both sides are normalized now, and an entry genuinely in the wrong
    directory is still flagged (#44).

  • investigation_search_all and investigation_find_duplicates search 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 a body:
    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_network and investigation_network page 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 take limit (default 50, 0 means no cap) and offset
    per direction, order each direction deterministically so a page never repeats
    or skips a row, and return the true totals plus truncated so a caller can
    tell what it did not see. The paging lives in query_network;
    get_outlinks still has no pagination parameters of its own.

  • The API returned 500s under ordinary concurrent read load: one SQLAlchemy
    Session was shared by every request.
    PyriteDB created a single
    Session in its constructor and the server cached one PyriteDB on app
    state, so the ~117 plain def handlers — which FastAPI runs on anyio's
    40-thread worker pool — all drove one session at once, with
    check_same_thread=False silencing 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 CI This 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 for QueuePool limit ... reached. verify_api_key no 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 named trg_entry_fts existed anywhere in the cluster, so a second Pyrite instance sharing one database in its own schema silently got no trigger — entry.fts_vector was never populated and keyword search returned no results, with no error.

  • A QUERY_SYNTAX error names the token the caller wrote instead of leading
    with SQLite's fragment (#67).
    detention AND third-party-doctrine reaches
    MATCH unquoted because the query carries an operator, and SQLite answer...

Read more