Skip to content

Errors

kennis raises a domain exception rather than letting an internal failure reach the terminal as a traceback. Every one of them derives from KennisError, and every one carries the command that resolves it, attached with add_note - that note is what becomes the hint: line.

The engine raises these. It never catches them to print: the front end decides what an error looks like. See Three layers.

The engine's exceptions.

Every error names the command that resolves it. There is no state-derived hint system deciding what advice a failure deserves; the error carries its own answer, chosen where the failure is raised and by nothing else.

The resolution is recorded twice, on purpose. It is attached with add_note, so it travels with the exception through a handler that knows nothing about kennis and prints unaided at the bottom of a traceback; and it is kept as an attribute, so a front end can render it in its own idiom - the command line as a hint: line under the failure - without parsing __notes__.

These are domain exceptions. An engine module raising a front end's error type is the inversion the engine/interface separation exists to forbid: the command line is what turns CorpusNotFound into Error: ... and an exit code, while the MCP server turns the same exception into a tool error.

KennisError

Bases: Exception

Something kennis could not do, and the command that would fix it.

A subclass sets default_resolution to the command that resolves it in the usual case. A caller with better information passes resolution; a caller that knows nothing resolves this particular failure passes resolution=None, which is also the base class's own default - an error with no fix to name should say so by omission rather than by a placeholder.

Source code in src/kennis/engine/errors.py
class KennisError(Exception):
    """Something kennis could not do, and the command that would fix it.

    A subclass sets `default_resolution` to the command that resolves it in
    the usual case. A caller with better information passes `resolution`;
    a caller that knows nothing resolves this particular failure passes
    `resolution=None`, which is also the base class's own default - an error
    with no fix to name should say so by omission rather than by a
    placeholder.
    """

    default_message = "kennis could not complete the operation"
    default_resolution: str | None = None

    def __init__(
        self,
        message: str | None = None,
        *,
        resolution: str | _Unset | None = _UNSET,
    ) -> None:
        super().__init__(message if message is not None else self.default_message)
        self.resolution = (
            self.default_resolution if isinstance(resolution, _Unset) else resolution
        )
        if self.resolution is not None:
            self.add_note(f"run `{self.resolution}`")

CorpusNotFound

Bases: KennisError

No corpus exists where kennis was told to look.

Source code in src/kennis/engine/errors.py
class CorpusNotFound(KennisError):
    """No corpus exists where kennis was told to look."""

    default_message = "no corpus found"
    default_resolution = "kennis corpus init"

ContextNotFound

Bases: KennisError

No .context/ bundle governs the directory kennis was run in.

The corpus is machine-global and a bundle is per-workspace, so this is CorpusNotFound one scope down - and it is a different failure from a bundle that exists and has no index, which is why the walk returns None rather than raising and lets the caller pick.

Source code in src/kennis/engine/errors.py
class ContextNotFound(KennisError):
    """No `.context/` bundle governs the directory kennis was run in.

    The corpus is machine-global and a bundle is per-workspace, so this is
    `CorpusNotFound` one scope down - and it is a different failure from a
    bundle that exists and has no index, which is why the walk returns None
    rather than raising and lets the caller pick.
    """

    default_message = "no context bundle found in this directory or any parent"
    default_resolution = "kennis context init"

PublishRefused

Bases: KennisError

Another write changed what this one prepared against while it was preparing, so publishing would overwrite it. Nothing was placed (14e).

The one write that is refused rather than kept waiting, because waiting would not help: what it prepared is out of date, and running it again prepares against what is there now.

Source code in src/kennis/engine/errors.py
class PublishRefused(KennisError):
    """Another write changed what this one prepared against while it was
    preparing, so publishing would overwrite it. Nothing was placed (14e).

    The one write that is refused rather than kept waiting, because waiting
    would not help: what it prepared is out of date, and running it again
    prepares against what is there now."""

    default_message = "the corpus changed while this was being prepared"

IndexBusy

Bases: KennisError

Another kennis process is rebuilding this collection's index.

The documents are writable while an index is rebuilt; only the rebuild has to wait - which is why remember writes the note and reports the index as deferred rather than losing it. Design section 16, concern #167.

Source code in src/kennis/engine/errors.py
class IndexBusy(KennisError):
    """Another kennis process is rebuilding this collection's index.

    The documents are writable while an index is rebuilt; only the
    rebuild has to wait - which is why
    `remember` writes the note and reports the index as deferred
    rather than losing it. Design section 16, concern #167.
    """

    default_message = "this index is being rebuilt by another kennis process"
    default_resolution = "kennis corpus index"

CorpusExists

Bases: KennisError

corpus init was asked for where a corpus already is.

Not a refusal to try again: initialising again would discard the history the first one made.

Source code in src/kennis/engine/errors.py
class CorpusExists(KennisError):
    """`corpus init` was asked for where a corpus already is.

    Not a refusal to try again: initialising again would discard the
    history the first one made.
    """

    default_message = "a kennis corpus is already here"
    default_resolution = "kennis corpus status"

HistoryUnwritable

Bases: KennisError

Git refused to record a change kennis made.

The failure is git's, for a reason it states - carried in the message - and retrying meets the same reason again. A publish that fails here is rolled back (14e), so the documents are as they were.

Source code in src/kennis/engine/errors.py
class HistoryUnwritable(KennisError):
    """Git refused to record a change kennis made.

    The failure is git's, for a reason it states - carried in the message -
    and retrying meets the same reason again. A publish that fails here is
    rolled back (14e), so the documents are as they were.
    """

    default_message = "the corpus history could not be written"
    default_resolution = None

GitUnavailable

Bases: KennisError

The git binary is missing.

kennis shells out to git rather than binding a library, and there is no fallback implementation, so this is a system requirement rather than a degraded mode.

Source code in src/kennis/engine/errors.py
class GitUnavailable(KennisError):
    """The `git` binary is missing.

    kennis shells out to `git` rather than binding a library, and there is no
    fallback implementation, so this is a system requirement rather than a
    degraded mode.
    """

    default_message = "the git binary was not found on PATH"
    default_resolution = "sudo apt install git"

SettingsError

Bases: KennisError

A setting is missing, malformed, or outside its permitted range.

Source code in src/kennis/engine/errors.py
class SettingsError(KennisError):
    """A setting is missing, malformed, or outside its permitted range."""

    default_message = "a setting could not be read"
    default_resolution = "kennis config show"

UnknownCollection

Bases: KennisError

A collection name kennis does not have.

There are three, fixed: notes, literature and docs. A fourth is not a configuration option - each of the three is a different processing layer over one ingestion pipeline, not a folder someone may add to.

Source code in src/kennis/engine/errors.py
class UnknownCollection(KennisError):
    """A collection name kennis does not have.

    There are three, fixed: `notes`, `literature` and `docs`. A fourth is not
    a configuration option - each of the three is a different processing layer
    over one ingestion pipeline, not a folder someone may add to.
    """

    default_message = "no such collection"
    default_resolution = "kennis corpus status"

ProjectNotKnown

Bases: KennisError

A name that is not one of the machine's known projects (14g).

Source code in src/kennis/engine/errors.py
class ProjectNotKnown(KennisError):
    """A name that is not one of the machine's known projects (14g)."""

    default_message = "no project by that name"
    default_resolution = "kennis context list"

BundleNotDeletable

Bases: KennisError

A listed project whose folder is not a bundle by both its name and its manifest, so kennis will not delete it (2026-11-04).

The list is paths read from a file; a delete aimed by it removes a whole tree, so it is aimed only at a .context folder holding bundle.json. Forgetting the project takes the entry off the list.

Source code in src/kennis/engine/errors.py
class BundleNotDeletable(KennisError):
    """A listed project whose folder is not a bundle by both its name and
    its manifest, so kennis will not delete it (2026-11-04).

    The list is paths read from a file; a delete aimed by it removes a
    whole tree, so it is aimed only at a `.context` folder holding
    `bundle.json`. Forgetting the project takes the entry off the list.
    """

    default_message = "that project's folder is not a context bundle kennis can delete"

ProjectListUnreadable

Bases: KennisError

The list of the machine's projects could not be read (14g).

No command repairs it: it is a list of paths, rebuilt as kennis touches each project again once the file is removed, so the message names the file and the resolution is left out.

Source code in src/kennis/engine/errors.py
class ProjectListUnreadable(KennisError):
    """The list of the machine's projects could not be read (14g).

    No command repairs it: it is a list of paths, rebuilt as kennis touches
    each project again once the file is removed, so the message names the
    file and the resolution is left out.
    """

    default_message = "the list of projects could not be read"

DocumentNotFound

Bases: KennisError

No document answers to the handle given.

Also raised for an ambiguous handle. Two documents sharing a citekey or a title make that key useless, and the honest answer is that it addresses nothing; resolving it to whichever was walked first would silently hand back the wrong paper.

Source code in src/kennis/engine/errors.py
class DocumentNotFound(KennisError):
    """No document answers to the handle given.

    Also raised for an *ambiguous* handle. Two documents sharing a citekey or
    a title make that key useless, and the honest answer is that it addresses
    nothing; resolving it to whichever was walked first would silently hand
    back the wrong paper.
    """

    default_message = "no such document"
    default_resolution = "kennis corpus list"

DocumentInvalid

Bases: KennisError

A document on disk does not validate against its collection's schema.

A missing field, a key kennis does not understand, or an owner from a vocabulary it does not recognise. Refused with the document named rather than guessed at: kennis ships no migration, because the corpora are rebuildable and there is no compatibility obligation.

Source code in src/kennis/engine/errors.py
class DocumentInvalid(KennisError):
    """A document on disk does not validate against its collection's schema.

    A missing field, a key kennis does not understand, or an `owner` from a
    vocabulary it does not recognise. Refused with the document named rather
    than guessed at: kennis ships no migration, because the corpora are
    rebuildable and there is no compatibility obligation.
    """

    default_message = "a document could not be read"
    default_resolution = "kennis corpus status"

InputError

Bases: KennisError

An argument names nothing kennis can act on.

Raised rather than collected as a failed outcome: it means the command as typed cannot be carried out, and running the rest of the batch would leave the user to notice the gap in a summary. A pattern that matched nothing is the clearest case - quietly adding nothing is the one outcome that looks like success and is not.

Source code in src/kennis/engine/errors.py
class InputError(KennisError):
    """An argument names nothing kennis can act on.

    Raised rather than collected as a failed outcome: it means the command as
    typed cannot be carried out, and running the rest of the batch would leave
    the user to notice the gap in a summary. A pattern that matched nothing is
    the clearest case - quietly adding nothing is the one outcome that looks
    like success and is not.
    """

    default_message = "nothing to act on"
    default_resolution = "kennis corpus add --help"

SourceUnreadable

Bases: KennisError

A source exists but its bytes could not be obtained.

An unreadable file, an unreachable URL, an encoding no ladder could decode. Distinct from a conversion failure: nothing was wrong with the converter, there was simply nothing to hand it.

Source code in src/kennis/engine/errors.py
class SourceUnreadable(KennisError):
    """A source exists but its bytes could not be obtained.

    An unreadable file, an unreachable URL, an encoding no ladder could
    decode. Distinct from a conversion failure: nothing was wrong with the
    converter, there was simply nothing to hand it.
    """

    default_message = "a source could not be read"
    default_resolution = "kennis corpus add --help"

ConverterUnavailable

Bases: KennisError

A format needs a converter that is not installed.

Checked once per batch rather than once per document: a folder of fifty PDFs would otherwise produce fifty copies of the same install instructions, one at a time, over a run that cannot succeed.

Source code in src/kennis/engine/errors.py
class ConverterUnavailable(KennisError):
    """A format needs a converter that is not installed.

    Checked once per batch rather than once per document: a folder of fifty
    PDFs would otherwise produce fifty copies of the same install
    instructions, one at a time, over a run that cannot succeed.
    """

    default_message = "no converter is available for this format"
    # Not an install command. Which one is right depends on how kennis was
    # installed, and only `Converter.install_hint()` knows - the one place
    # this is raised passes it. This default is the fallback, and it has to
    # be a command that runs as printed either way. Concerns #185, #195.
    default_resolution = "kennis config get conversion.backend"

ConversionFailed

Bases: KennisError

A converter ran and produced nothing usable for a document.

Per document, not per run. A converter converts the documents it can and records the rest, so one document's failure must not cost the others.

Source code in src/kennis/engine/errors.py
class ConversionFailed(KennisError):
    """A converter ran and produced nothing usable for a document.

    Per document, not per run. A converter converts the documents it can and
    records the rest, so one document's failure must not cost the others.
    """

    default_message = "a document could not be converted"
    default_resolution = "kennis corpus add --help"

ConverterRefused

Bases: ConversionFailed

The converter turned kennis away, whatever the document: a hosted service rejecting its API key.

Not one document's failure, so it ends the add rather than being filed against each document in turn - where its remedy was dropped and the same refusal was paid for once per file.

Source code in src/kennis/engine/errors.py
class ConverterRefused(ConversionFailed):
    """The converter turned kennis away, whatever the document: a hosted
    service rejecting its API key.

    Not one document's failure, so it ends the add rather than being filed
    against each document in turn - where its remedy was dropped and the
    same refusal was paid for once per file.
    """

    default_message = "the converter refused every document"
    default_resolution = "kennis config init"

NothingToIndex

Bases: KennisError

There is no text to build an index over.

Raised before the index is built rather than translated afterwards, because the failure it replaces is ValueError: max() iterable argument is empty raised from inside bm25s's vocabulary build - and, under filterwarnings = ["error"], three RuntimeWarnings that fire before that and surface instead of it. Neither names anything a user can act on.

The condition is "no tokens survived tokenisation", not "no documents": stopwords are removed before the vocabulary is built, so a collection of documents that are all stopwords fails in exactly the same place as an empty one.

Source code in src/kennis/engine/errors.py
class NothingToIndex(KennisError):
    """There is no text to build an index over.

    Raised before the index is built rather than translated afterwards,
    because the failure it replaces is `ValueError: max() iterable argument
    is empty` raised from inside bm25s's vocabulary build - and, under
    `filterwarnings = ["error"]`, three `RuntimeWarning`s that fire before
    that and surface instead of it. Neither names anything a user can act on.

    The condition is "no tokens survived tokenisation", not "no documents":
    stopwords are removed before the vocabulary is built, so a collection of
    documents that are all stopwords fails in exactly the same place as an
    empty one.
    """

    default_message = "there is nothing to index"
    default_resolution = "kennis corpus add"

EmbeddingUnavailable

Bases: KennisError

An embedding backend could not produce vectors.

Named separately from SettingsError because the configuration may be perfectly correct and the daemon simply not running. The message carries the backend and the model, since a failure from inside an HTTP client says nothing about which of three configured backends was being asked, or for what.

Source code in src/kennis/engine/errors.py
class EmbeddingUnavailable(KennisError):
    """An embedding backend could not produce vectors.

    Named separately from `SettingsError` because the configuration may be
    perfectly correct and the daemon simply not running. The message carries
    the backend and the model, since a failure from inside an HTTP client
    says nothing about which of three configured backends was being asked,
    or for what.
    """

    default_message = "the embedding backend could not be reached"
    default_resolution = "kennis config get embedding"

SearchUnavailable

Bases: KennisError

The index cannot answer the question as asked.

Distinct from an absent index, which is NothingToIndex: this one is built and readable, and the request does not fit it - a dense query against a lexical-only index, or a document that is not in it. The message names what would work instead.

Source code in src/kennis/engine/errors.py
class SearchUnavailable(KennisError):
    """The index cannot answer the question as asked.

    Distinct from an absent index, which is `NothingToIndex`: this one is
    built and readable, and the request does not fit it - a dense query
    against a lexical-only index, or a document that is not in it. The
    message names what would work instead.
    """

    default_message = "this index cannot answer that"
    default_resolution = "kennis search --help"

PackInvalid

Bases: KennisError

A .ken.yml file does not validate against the schema.

Design section 5, step 1: refuse naming the field path, because a message that says only "invalid" makes the author hunt through a file that may declare hundreds of documents. Separate from the two version refusals - a schema_version kennis does not understand and a min_version above its own - which are not defects in the file and are resolved by upgrading rather than by editing.

Source code in src/kennis/engine/errors.py
class PackInvalid(KennisError):
    """A `.ken.yml` file does not validate against the schema.

    Design section 5, step 1: refuse naming the field path, because a
    message that says only "invalid" makes the author hunt through a file
    that may declare hundreds of documents. Separate from the two version
    refusals - a `schema_version` kennis does not understand and a
    `min_version` above its own - which are not defects in the file and are
    resolved by upgrading rather than by editing.
    """

    default_message = "a pack file could not be read"
    default_resolution = "kennis pack validate"

PackNotInstalled

Bases: KennisError

No pack of that id is in this machine's store.

Named rather than treated as a success, because the realistic cause is a mistyped id and reporting "removed" for a pack that was never there tells the user the thing they meant is gone.

Source code in src/kennis/engine/errors.py
class PackNotInstalled(KennisError):
    """No pack of that id is in this machine's store.

    Named rather than treated as a success, because the realistic cause is
    a mistyped id and reporting "removed" for a pack that was never there
    tells the user the thing they meant is gone.
    """

    default_message = "no pack of that id is installed"
    default_resolution = "kennis pack list"

BackupRefused

Bases: KennisError

A backup or a restore that kennis will not start.

An empty password, a folder that is not there or cannot be written, a restore onto something that is not empty, or an archive naming a path outside the corpus. Each is refused before anything is written, so a refusal never leaves a partial file or a half-restored corpus.

Source code in src/kennis/engine/errors.py
class BackupRefused(KennisError):
    """A backup or a restore that kennis will not start.

    An empty password, a folder that is not there or cannot be written,
    a restore onto something that is not empty, or an archive naming a
    path outside the corpus. Each is refused before anything is written,
    so a refusal never leaves a partial file or a half-restored corpus.
    """

    default_message = "the backup could not be started"
    default_resolution = None

BackupUnreadable

Bases: KennisError

An archive that would not open, or failed its test.

One error for two causes, because they cannot be told apart: with the names encrypted, a wrong password decrypts the header into arbitrary bytes, and the library then fails however those bytes lead it to.

Source code in src/kennis/engine/errors.py
class BackupUnreadable(KennisError):
    """An archive that would not open, or failed its test.

    One error for two causes, because they cannot be told apart: with
    the names encrypted, a wrong password decrypts the header into
    arbitrary bytes, and the library then fails however those bytes
    lead it to.
    """

    default_message = "the password is wrong or the archive is damaged"
    default_resolution = None

ServiceUnavailable

Bases: KennisError

The corpus's service could not be started or reached (milestone 14).

Source code in src/kennis/engine/errors.py
class ServiceUnavailable(KennisError):
    """The corpus's service could not be started or reached (milestone 14)."""

    default_message = "the kennis service did not start"
    default_resolution = None

McpExtraMissing

Bases: KennisError

kennis serve in an installation without the mcp extra, which is what supplies fastmcp. A traceback there reached an MCP client only as a closed connection (2026-11-01).

Source code in src/kennis/engine/errors.py
class McpExtraMissing(KennisError):
    """`kennis serve` in an installation without the `mcp` extra, which is
    what supplies fastmcp. A traceback there reached an MCP client only as a
    closed connection (2026-11-01)."""

    default_message = (
        "the MCP server needs the `mcp` extra, which this installation of "
        "kennis does not have"
    )
    default_resolution = 'uv tool install --reinstall "kennis[mcp]"'

ClientOutdated

Bases: ServiceUnavailable

This process loaded kennis before kennis was reinstalled under it (#601). Replacing the service would start one from the new code, which this process could not read; no command fixes it, a restart does.

Source code in src/kennis/engine/errors.py
class ClientOutdated(ServiceUnavailable):
    """This process loaded kennis before kennis was reinstalled under it
    (#601). Replacing the service would start one from the new code, which
    this process could not read; no command fixes it, a restart does."""

    default_message = (
        "this kennis process was started before kennis was reinstalled, so it "
        "cannot speak to the service the new installation runs; restart the "
        "program that started it (in Claude Code, `/mcp reconnect kennis`)"
    )
    default_resolution = None

TaskCancelled

Bases: BaseException

A task stopping part way because someone asked it to (Brian, 2026-10-27), raised in the task's own thread at the next point that can stop - before anything is published, so a cancelled write left the corpus as it was.

Not a KennisError, nor an Exception, as asyncio's CancelledError is not since Python 3.8, and for the same reason: a loop that records one item's KennisError as a failed item and carries on would count a cancellation as one more failure and keep going. What a cancelled task reports is TaskStopped, which the service makes of this once it reaches the top. The message follows the word "Cancelled", which every front end puts before it.

Source code in src/kennis/engine/errors.py
class TaskCancelled(BaseException):
    """A task stopping part way because someone asked it to (Brian,
    2026-10-27), raised in the task's own thread at the next point that can
    stop - before anything is published, so a cancelled write left the
    corpus as it was.

    **Not a `KennisError`, nor an `Exception`**, as asyncio's
    `CancelledError` is not since Python 3.8, and for the same reason: a
    loop that records one item's `KennisError` as a failed item and carries
    on would count a cancellation as one more failure and keep going. What
    a cancelled task reports is `TaskStopped`, which the service makes of
    this once it reaches the top. The message follows the word
    "Cancelled", which every front end puts before it.
    """

    default_message = "nothing was changed"

    def __init__(self, message: str | None = None) -> None:
        super().__init__(message if message is not None else self.default_message)

TaskStopped

Bases: KennisError

A task's outcome when it was cancelled: the sentence TaskCancelled carried, as a refusal every front end already knows how to report.

Source code in src/kennis/engine/errors.py
class TaskStopped(KennisError):
    """A task's outcome when it was cancelled: the sentence `TaskCancelled`
    carried, as a refusal every front end already knows how to report."""

    default_message = TaskCancelled.default_message

ServiceBusy

Bases: KennisError

A stop asked of the service while a task is queued or running, which would end the task wherever it was.

Source code in src/kennis/engine/errors.py
class ServiceBusy(KennisError):
    """A stop asked of the service while a task is queued or running, which
    would end the task wherever it was."""

    default_message = "the kennis service is running a task and was left running"
    default_resolution = "kennis service status"

CitationStyleUnreadable

Bases: KennisError

The .csl file literature.citation_style names cannot be read as a citation style. Empty, the setting uses the style kennis ships.

Source code in src/kennis/engine/errors.py
class CitationStyleUnreadable(KennisError):
    """The `.csl` file `literature.citation_style` names cannot be read as a
    citation style. Empty, the setting uses the style kennis ships."""

    default_message = (
        "the citation style named by literature.citation_style is unreadable"
    )
    default_resolution = 'kennis config set literature.citation_style ""'