This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
recoll-modern/docs/DECISIONS.md
T

30 KiB
Raw Blame History

Decisions

Append-only, newest first. Never edit or delete an entry — supersede it with a new one that links back. One entry per decision a future maintainer would otherwise have to reverse-engineer.

2026-09-28 — Default registry login to the triggering Gitea account

Status: accepted Context: The first publish run received REGISTRY_TOKEN but no REGISTRY_USERNAME, so Docker login stopped with username is empty. A working Gitea workflow uses gitea.actor for that login. Decision: default the registry username to Gitea's GitHub-compatible github.actor context; an explicit REGISTRY_USERNAME secret overrides it when the token belongs to another account. Alternatives: require a second secret for the common case (caused the first-run failure); always use the repository owner (wrong for a token owned by a collaborating account). Consequences: one package-write PAT is enough when its owner triggers the push. Other token owners must supply the optional username secret.


2026-09-28 — Isolate registry credentials and tag the built image ID

Status: accepted Context: A persistent Gitea runner can retain Docker login credentials, and jobs sharing a Docker daemon can overwrite a common local image tag while publishing concurrently. Decision: the publish script uses a fresh temporary Docker config, removes it on exit, and tags the immutable ID returned by its own build. Document isolated runner jobs as a deployment prerequisite. Alternatives: log in to the runner's default Docker config (credential residue); tag a shared local name (revision race); serialize all image jobs (unnecessary when each job tags its own image ID). Consequences: routine success and failure paths remove the PAT, and overlapping jobs do not reuse each other's local image tag. Runner isolation still matters for abrupt host failures and untrusted jobs.


2026-09-28 — Publish one amd64 image through Gitea Actions

Status: accepted Context: The Docker deployment exists, but operators must build it locally. Gitea's documented job token limitation means the workflow needs separate package credentials. Decision: run checks and build on pull requests, main and version tags. After checks, publish gitea.lucaswinther.info/lucasw89/recoll-modern:main for main and :vX.Y.Z plus :latest for a full version tag. Use a package-write personal access token stored as repository Actions secrets. The first published architecture is linux/amd64; the existing local build remains available. Alternatives: tag every main commit as latest (makes releases ambiguous); use Buildx for multiple architectures now (requires more runner setup); use GITEA_TOKEN (currently lacks package publishing). Consequences: operators can pin a version, while main is a development image. Publishing requires a Docker-capable Gitea runner and configured secrets; no external deploy occurs automatically.


2026-09-28 — Reject non-regular explicit sources before opening

Status: accepted Context: Directory discovery filters non-regular entries, but an explicit file source could name a FIFO. Opening that path for hashing blocks while waiting for a writer. Decision: open explicit sources with O_NONBLOCK and O_NOFOLLOW, inspect the opened descriptor's file mode, and return file_hash_failed for non-regular sources. Retain the existing before/after metadata checks for content changes. Alternatives: rely on directory discovery alone (explicit sources bypass it); open every path first (can block on a FIFO). Consequences: an explicit FIFO, including one swapped into place immediately before opening, is reported as a file error without blocking the worker.


2026-09-28 — Keep exception dataclasses mutable for transaction propagation

Status: accepted Context: Python's generator-based context managers assign __traceback__ while re-raising an exception. Frozen AppError and PathPolicyError dataclasses rejected that assignment, replacing an expected application error with TypeError when it crossed Database.transaction(). Decision: retain slots=True but remove frozen=True from both exception classes. Alternatives: move all error checks outside transactions (introduces races); wrap every exception at each transaction boundary (duplicates handling and can change error identity). Consequences: the original application or path error survives rollback and reaches its caller. The transaction regression test covers both exception types.


2026-09-28 — Guard generated API documentation with the service session

Status: accepted Context: FastAPI exposes generated OpenAPI and documentation routes outside ordinary route dependencies. The accepted spec requires authentication for every route except health and login while also requiring OpenAPI to work under a configured mount prefix. Decision: enforce the existing session check on the generated routes in prefix middleware after path normalization. Keep OpenAPI and the documentation pages available to logged-in clients. Alternatives: leave documentation public (violates R24); disable it (violates the intended OpenAPI surface under R29); add a separate authentication mechanism for documentation (unnecessary state). Consequences: generated documentation shares the API's cookie authentication and URL prefix.


2026-09-27 — Persist replay paths and pin original downloads

Status: accepted Context: Review found that a job could mutate rule or collection state before its indexing step failed, leaving a retry without the old paths. FileResponse also reopened a path after containment checking, allowing a concurrent host-side symlink swap. Decision: persist affected paths in a running job before rule mutations; enqueue collection cleanup atomically with collection deletion. Open original files beneath the document root with no-follow directory descriptors and stream from that checked descriptor, including byte-range responses. Alternatives: recompute old paths on retry (the database may already have lost them); retain FileResponse after a path-only check (it reopens the path). Consequences: interrupted jobs retain their required reindex paths. File responses are tied to an opened regular file even if directory entries change later. The descriptor traversal relies on Linux openat semantics supplied by the current Docker deployment; desktop packaging must revisit it.


2026-09-18 — Cross-platform desktop packaging is spec 005

Status: accepted Supersedes: "The product is four specs, built in order" (below, same date), only by appending a fifth delivery after 004; the original order and boundaries of specs 001–004 remain unchanged. Context: the hosted Docker service should eventually also be usable as a self-contained installable application on Linux, macOS and Windows, without distracting from delivery of the core product. Decision: desktop distribution is spec 005 and starts only after 004. Its intended architecture is a Tauri-style shell that reuses the web UI and launches the existing HTTP API plus Recoll as bundled local sidecars. Tauri is the leading candidate, not a selected dependency; that choice belongs to 005. Alternatives: call backend libraries directly from a native shell (creates a second integration path); hide Docker behind a launcher (still requires a container runtime and integrates poorly with the desktop filesystem). Consequences: specs 001–004 stay Docker-first, but application logic cannot depend on Docker. State paths and listen addresses are configurable, lifecycle is explicit, and UI behavior crosses the HTTP API. Spec 005 must resolve per-platform Recoll packaging and licensing, filters, loopback security, signing, updates and installer formats before adding dependencies.


2026-09-18 — Deployment supports a configured URL subpath

Status: accepted Context: the service may sit behind an existing reverse proxy under a path such as /recoll, not at the origin root. Hard-coded root URLs break redirects, cookies, OpenAPI and document links there. Decision: one configured external mount prefix drives routing, generated URLs and cookie scope. Forwarded scheme, host and prefix values are honored only from explicitly trusted proxies. Alternatives: root-only hosting (needlessly constrains deployment); trust every forwarded header (lets a direct client forge external URLs and cookie paths). Consequences: API and end-to-end tests run at both / and a non-root prefix. Proxy configuration is part of deployment documentation, and all URL generation goes through one boundary.


2026-09-18 — Search result fields are caller-selected

Status: accepted Supersedes: the fixed result shape in "Search results carry metadata and snippets, not full text" (below, same date). The metadata-plus-snippets shape remains the default, not the only representation. Context: browser and MCP consumers have different response-size needs, and users need to choose whether results include metadata, snippets, extracted text or retrieval links. Decision: document_id is unconditional; callers select all other result fields. Inline extracted text is opt-in, bounded and marked when truncated. Authenticated endpoints return full extracted text and stream the canonical original separately. Alternatives: one fixed rich shape (wastes bandwidth and agent context); metadata-only search with a follow-up per hit (too many round trips); unbounded full text inline (a book can dominate the response). Consequences: field projection is part of the engine/API contract. Generated retrieval links honor the configured mount prefix, and original-file responses require range and safe-disposition handling.


2026-09-18 — Directory membership has normalized additive provenance

Status: accepted Context: users need both one-time directory expansion and persistent directory membership, including the ability to omit metadata subdirectories such as .calibre. Overlapping sources must not make removal order-dependent. Decision: direct file additions and snapshot expansions are durable direct membership. Persistent directory rules materialize their own contributed documents and hold exact recursive exclusions scoped to the rule. Per-file removal creates an override against rule-derived membership; an explicit direct addition still wins. Persistent rules reconcile at startup and explicit reindex, not through watching. Alternatives: one polymorphic source ledger (weaker relational constraints); recompute every rule on demand (slow and nondeterministic); persistent rules without provenance (removing one rule can erase membership contributed by another). Consequences: the schema has separate direct, rule-derived and exclusion tables. Directory symlinks are never followed; file symlinks are accepted only inside the document root. Jobs default to best-effort, while strict jobs scan first and apply no membership changes if any file fails.


2026-09-18 — Document identity is a content hash, not a path

Status: accepted Context: the document set may contain files that share a name but differ in content, and files that differ in name but are byte-identical. Keying membership on path merges neither and distinguishes neither correctly. Decision: a document is identified by the SHA-256 of its bytes. documents is keyed by that hash, document_paths maps many paths to one document, and membership attaches to the document. Each document has one canonical path, and only that path is reported to Recoll as belonging to any collection. Alternatives: path as identity (byte-identical copies appear as separate hits for the same content); relying on Recoll to deduplicate (it cannot — md5 is empty and sig is only size + mtime). Consequences: duplicate copies are indexed by Recoll but belong to no collection, so the required collection scope makes them unreachable — verified: a scoped search over a tree with one duplicated file returned it once. Deleting a canonical path must promote another path and force a reindex, or the document silently leaves every collection.


2026-09-18 — Membership is supplied at index time; documents are never written to

Status: accepted Supersedes: "One index; collection membership is an opaque per-file tag" (below, same date), which carried membership in a user.collection extended attribute. That entry's xattr mechanism must not be implemented. Context: the service must work with read-only access to the documents it indexes. Writing an xattr requires write access, so the superseded design fails outright on a :ro mount — verified: setfattr returns "Read-only file system". Decision: membership reaches Recoll through metadatacmds in recoll.conf (metadatacmds = ; collection = <helper> %f), a documented Recoll facility for external metadata. Recoll runs the helper per file at index time; the helper answers from our own store. Everything the superseded entry established about opaque IDs, multi-membership and single-ranked-set OR queries still holds — only the transport changes. Alternatives: filtering Recoll's results against SQLite after the query (breaks paging and total counts); one index per collection (cannot express two files in one directory belonging to different collections); requiring a writable document volume (contradicts the requirement). Consequences: SQLite becomes the only store of membership rather than the authoritative copy of two, so drift between store and index is now impossible and no reconcile path is needed. Extended attributes are no longer a deployment requirement. A membership change needs recollindex -i -Z <path> — verified that neither a plain incremental run nor recollindex -i notices it, because Recoll's change signature is size + mtime and a membership change touches neither. The helper is spawned once per file per index run, so it must start fast: over 400 files, indexing took 0.09s with no helper, 0.13s with a shell+awk helper and 0.82s with a python3 helper. The service exports a flat map from SQLite for a small non-Python helper to read.


2026-09-18 — Search results carry metadata and snippets, not full text

Status: accepted Context: Recoll can return metadata, keyword-in-context snippets, extracted plain text and the original file. The response shape drives both the web UI and the MCP tool. Decision: a search hit carries title, author, path, MIME type, size, date, score, its collections and Recoll's snippets. Extracted text and file download are separate endpoints. Alternatives: metadata only (a results list without context is markedly worse, and an MCP client would fetch every candidate to judge it, costing more overall); metadata plus full extracted text (responses become enormous for book-length PDFs and blow an agent's context window). Consequences: one search call is enough for an agent to judge relevance. The UI needs a second call to open a document, which is the right trade.


2026-09-18 — Authentication in 001, user administration in 004

Status: accepted Context: the service needs real auth, but roles, user CRUD and per-collection permissions are a subsystem in their own right, and PRODUCT.md currently describes a single technical operator. Decision: 001 ships genuine authentication — argon2 password hashing, server-side sessions, an admin account seeded from the environment at first boot, every endpoint behind an auth dependency. User administration and permissions are deferred to spec 004. Alternatives: no auth in the service, trusting a reverse proxy (a misconfigured proxy or an exposed Docker port would leave the destructive endpoints naked); the full identity system in 001 (a large addition to an already-grown spec, building for users who do not exist yet). Consequences: 001 is safe to expose behind a proxy. 004 must extend the auth layer without reshaping endpoints, so the auth dependency is designed as a seam from the start.


2026-09-18 — Search scope is an explicit, required parameter

Status: accepted Context: docs/ARCHITECTURE.md proposed the invariant that a query never silently spans collections. A Recoll query with no collection clause returns every document, including untagged ones. Decision: every search names one or more collections; omitting the parameter is an error, not an implicit "all". Multiple collections are OR-ed into one Xapian query so ranking stays coherent and each hit reports which collections it belongs to. Alternatives: single-collection-only searches (the UI and MCP would be built around a model they later outgrow); a searchable-by-default flag (reintroduces the implicit scope the invariant forbids); per-collection fan-out and application-side merging (scores from separate databases are not comparable, so the merged order would be strictly worse than letting Xapian rank). Consequences: the API layer enforces the invariant, because Recoll will not. Every search path needs a test proving an unscoped request is rejected.


2026-09-18 — Indexing runs as an in-process job queue

Status: accepted Context: collections are created at runtime, so something must index a collection the moment it is created, and report progress back to the UI and to MCP clients. Decision: creating a collection, changing membership, or calling the reindex endpoint enqueues a job. A background worker in the same FastAPI process runs recollindex and records status, progress and errors in SQLite. One container process, no broker. Alternatives: recollindex -m real-time monitoring (one supervised process per collection, inotify watch limits, and a new collection still needs a first full index from somewhere — revisit as an opt-in mode later); cron-scheduled full runs (new collections sit empty until the next tick, and there is no way to force a refresh). Consequences: jobs die with the process, so the queue must recover interrupted runs at startup. Job state is queryable, so "indexing, 40%" is available to every consumer.


2026-09-18 — Collections are runtime-managed through the API

Status: accepted Context: collections could be declared in a mounted config file or managed at runtime. Decision: collections are created, edited and deleted through the HTTP API, with SQLite as the store of record. The service creates and maintains the index state on demand. Alternatives: a declarative mounted collections.yaml (reproducible and git-friendly, but no management UI without adding a write path later); one collection per subdirectory by convention (forces the on-disk layout to match the collection layout, which the per-file membership requirement rules out). Consequences: 001 grows a persistence layer, validation and migrations. AGENTS.md makes deleting or rebuilding an index an ask-first operation, so the delete path needs an explicit guard rather than a bare DELETE.


2026-09-18 — One index; collection membership is an opaque per-file tag

Status: accepted Context: a collection cannot be "a directory", because files sharing a directory must be able to belong to different collections. Verified in a throwaway Recoll 1.43.0 container — details and the exact config in docs/ARCHITECTURE.md under "Verified Recoll mechanics". Decision: index every document once into a single Recoll index. Membership is a user.collection extended attribute mapped to a queryable Recoll field. The tag is an opaque single-token ID; the human-readable name lives only in SQLite. Alternatives: one index per collection with rule-based selection (a file in two collections is stored and indexed twice and can be returned twice, and hand-picking files in a shared directory is awkward); an explicit file list driving indexing (does not map onto Recoll's topdirs-walking model). Why opaque IDs: Recoll tokenises the field value and matches case-insensitively, so a tag of cook-books is also matched by collection:cook and collection:books, and TechBooks by collection:techbooks. Names-as-tags would let collections silently bleed into each other — exactly what the no-silent-spanning invariant forbids. Opaque IDs tested clean, with no partial matches. Consequences: collection names need no character restrictions and renaming never touches the index. Documents must sit on a volume supporting extended attributes, checked at startup — without it every scoped search silently returns nothing. Retagging costs recollindex -i <file>, not a rebuild, because a plain incremental run does not notice xattr-only changes.


2026-09-18 — Stack: Python + FastAPI, managed with uv

Status: accepted Context: the stack was unchosen and is an ask-first decision. Recoll ships first-class Python bindings; every other language must drive recollq as a subprocess and parse its output. Decision: Python + FastAPI for the engine and HTTP API, with uv as the package manager. uv 0.12.16 was installed on this host as part of this decision, and AGENTS.md updated in the same commit. Alternatives: TypeScript on Node/Bun or C# on ASP.NET Core (one language across UI, API and MCP, but both make a fragile output parser load-bearing); a split Python engine behind a TypeScript API (two runtimes and an extra hop inside one container). Consequences: structured results with no parsing layer, and OpenAPI for free, which specs 002 and 003 can generate clients from. The web UI will likely be a second language; that boundary is the HTTP API.


2026-09-18 — The product is four specs, built in order

Status: accepted Context: "a web UI and an MCP server over Recoll" is four subsystems. One spec covering all of them would be too large to plan or review, and PRODUCT.md is still an uninterviewed stub, so any UI spec written now would design against fiction. Decision: 001 search engine + API, 002 web UI, 003 MCP server, 004 multi-user management. Each gets its own spec, plan and review cycle. The table lives in docs/ARCHITECTURE.md. Alternatives: one thin vertical slice through all four (would lock in a stack and a UI before PRODUCT.md is real); engine plus MCP together (defensible, but it widens 001, which has already grown). Consequences: 002 is blocked on /impeccable init. 001's HTTP API is the contract 002 and 003 are both built against, so it is the piece that must be got right.


2026-09-18 — Override the superpowers artefact paths

Status: accepted Context: superpowers 6.3.0 writes specs to docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md and plans to docs/superpowers/plans/YYYY-MM-DD-<feature>.md (verified in the installed skill files). That nests a second docs tree and dates artefacts that we want numbered and paired. Decision: specs -> docs/specs/NNN-slug.md, plans -> docs/plans/NNN-slug.md, plan number matching its spec number. Recorded in AGENTS.md so every session picks it up. Alternatives: accept the defaults (two docs trees, no spec/plan pairing); symlink (fragile on Windows and in containers). Consequences: the override must be restated if AGENTS.md is ever trimmed; a superpowers upgrade that changes its defaults needs this entry re-checked.


2026-09-18 — Adopt AGENTS.md as single source of truth

Status: accepted Context: The repository was empty — no commits, no stack, no agent context. Agents had nothing durable to read and would re-explore from scratch every session. Decision: AGENTS.md is canonical; CLAUDE.md and GEMINI.md are thin pointers with no rules of their own. Enforceable things live in .claude/settings.json and .gitignore rather than in prose. Alternatives: per-tool rule files (drift and synchronisation debt); no rules file (measurably more steps and tokens per task). Consequences: rule changes happen in one file; the pointer files must stay thin; AGENTS.md is an executable-context and prompt-injection surface, so changes to it get reviewed like CI config.


2026-09-18 — No DESIGN.md until the first UI exists

Status: accepted Context: The impeccable skill reads PRODUCT.md and DESIGN.md. PRODUCT.md could be written from the stated product intent; DESIGN.md could not — there is no UI, no tokens, no visual world. Decision: write PRODUCT.md now; leave DESIGN.md absent. The first UI build produces it (/impeccable document derives it from shipped code). Alternatives: a placeholder DESIGN.md — rejected, it would assert a visual system that does not exist and the skill would design against fiction. Consequences: the first UI task must end by writing DESIGN.md, not skip it.


2026-09-26 — Retrieve text by Recoll UDI and expose rank-based score

Status: provisional until the real-container contract runs Context: the approved plan proposed an escaped path query for complete text and a relevance score. Recoll's Python API documents fsudi.fs_udi(path) and Db.getDoc() for exact path lookup, but does not document a numeric query weight on returned documents. Decision: derive the UDI from the canonical path for full-text extraction. Set score to the reciprocal result rank, preserving Recoll's order across pages while making no claim that the number is Xapian's raw weight. Alternatives: a query-language path clause that must safely encode quotes and parentheses; an undocumented Recoll binding attribute for score. Consequences: unusual filenames do not enter a query parser. Clients can compare score order within one query, but the value is a rank proxy. Revisit this once the real Recoll container can be tested.


2026-09-26 — Rotate CSRF tokens on session reads

Status: accepted Context: sessions store only SHA-256 token digests, but GET /auth/session must return a usable CSRF token after a page reload. The raw token cannot be recovered from SQLite. Decision: authenticate the session, replace its stored CSRF digest, and return a newly generated token. The previous token becomes invalid immediately. Alternatives: store the raw CSRF token; derive a repeatable token from the cookie and a server key. Consequences: clients should refresh their CSRF token after calling the session endpoint. Session storage contains no raw cookie or CSRF token.


2026-09-26 — Persist writable Recoll config beside service state

Status: accepted Context: Debian Recoll 1.43 writes generated support files into its config directory. A non-root container cannot write to image-owned /etc/recoll-modern; a live first-boot test surfaced this. Decision: keep immutable templates under /etc/recoll-modern, copy each missing template into /var/lib/recoll-modern/recoll-config on startup, and point both the engine and indexer there. Never overwrite an existing runtime config file. Keep the Recoll index in its own named volume. Alternatives: chown /etc/recoll-modern in the image (runtime changes would not survive container replacement); run the service as root. Consequences: service-state backups include generated Recoll configuration, and config changes made by an operator persist across image upgrades. Template updates require an explicit migration if an existing config should adopt them.


2026-09-26 — Treat an empty Recoll document URL as a missing hit

Status: accepted Context: the live Recoll 1.43 Python binding returns a Doc object with an empty getbinurl() for a UDI absent from the index; it does not return None. Passing that object to rclextract.Extractor raises an internal-file error. Decision: check the returned URL before extraction and return empty text for an absent UDI. Alternatives: catch the generic extraction error, which would hide genuine extraction failures. Consequences: exact-path text lookup follows the engine contract for missing documents while real extraction failures still surface as engine_unavailable.


2026-09-28 — Install headless Recoll command package in the published image

Status: accepted Context: Gitea's registry is behind Cloudflare's 100 MB request limit. The Docker image's Debian recoll metapackage pulled in recollgui and Qt WebEngine, producing a 273 MB compressed layer. The registry rejected that layer with HTTP 413, so no image manifest was published. Decision: install Debian's recollcmd package instead. It supplies recollindex and recollq without the unused graphical interface. Keep the Python binding and document filters, and check the command binaries during the image build. Alternatives: bypass Cloudflare for registry uploads; split the same GUI dependencies across Docker layers. A split-package probe still produced a 253 MB compressed layer. Consequences: the service remains headless. The full image's largest compressed layer is 39.02 MB, and a local container confirms both Recoll commands and the Python binding are present. Registry publishing and pulling still require a successful Gitea Actions run.


2026-09-28 — Keep Docker state beside the Compose file

Status: accepted Context: named Docker volumes hide the index and service database from operators managing a deployment from its Compose directory. The non-root container needs writable state owned by UID/GID 10001:10001. Decision: both Compose variants bind-mount ./data/service-state and ./data/recoll-index. The direct docker run example uses the same local directories. Ignore data/ in Git and require operators to create the directories with the container's ownership before startup. Alternatives: retain Docker-managed named volumes and require volume-specific backup commands. Consequences: moving the Compose file changes the resolved data paths. Existing named-volume deployments must copy both stores into the local directories before switching mounts; neither store is deleted or rebuilt by this change.