seamless docs

Domain invariants

The rules plausible-looking code breaks - supersession, scope resolution, FTS and LIKE escaping, LLM degradation - and why each exists.

These are the rules that reasonable code violates. Every one of them has a sensible-looking alternative that compiles, passes review, and is wrong; each is enforced somewhere specific, and the pointer here is where to look, not a substitute for reading it.

Supersession

Enforced in internal/lifecycle. Concepts: Memory & notes.

invalid_at is the only authoritative "has left the indexes" predicate

core.Memory.Active() is InvalidAt == nil; SQL filters WHERE invalid_at IS NULL.

Never infer live-ness from an empty superseded_by. An archived memory has invalid_at set and superseded_by empty - it is equally gone, and it does not point anywhere because nothing replaced it. Code that reads "no replacement, therefore still valid" resurrects every archived memory into briefings and recall at once, which is exactly the class of thing nobody notices until an agent acts on a retired constraint.

lifecycle.Supersede and lifecycle.Archive are the only writers

They are the only non-test writers of invalid_at and superseded_by. MCP, the gardener, and the console all route through them. Do not stamp those fields by hand.

They exist as one path because a supersession is not a field update: it stamps the old memory, appends a tombstone to its body, rewrites its file, and updates the index. Every hand-rolled version of that has been a subset of it.

valid_from is never touched by supersession

[valid_from, invalid_at) is the memory's bi-temporal record: when it started being believed, and when it stopped. Moving valid_from on supersession would erase the first half and, with it, the ability to ask what the store believed at some past moment.

It is stamped exactly once, guarded on the way in

The guards are:

Condition Error
old is already invalid ErrAlreadyInvalid
old.ID == replacement.ID ErrSelfSupersede
replacement is invalid or empty ErrInvalidReplacement

Acyclicity falls out of those guards rather than out of any traversal. Every edge points from an invalid memory to an active one, and nothing ever clears invalid_at - so no cycle can form. There is no cycle check to lean on, because there is nothing for one to find. Weaken a guard and you do not get a slow cycle check; you get cycles.

Pass the FULL body

Both functions append a tombstone to old.Body and rewrite the whole file. Handing them an index row - which carries no body - truncates the memory to just the tombstone. Re-read the file first.

This is the invariant most likely to be broken by code that looks correct: the index row has every field the signature wants, so it type-checks, writes successfully, and destroys the memory's content.

A superseded name stays occupied

Reviving it is ErrPathOccupied, never a silent clobber. The superseded memory's tombstone file still sits at memory/{project}/{name}.md, and overwriting it would destroy the readable history that supersession exists to preserve. memory_delete is the only escape hatch, and it drops that history - which is why it is the escape hatch and not the default.

A failed supersede is a tool ERROR, not a payload

When memory_write writes the new memory but the supersession fails, the call returns an error naming the still-active target. It never returns a success payload with an error nested inside it.

The reason is what an agent does with each. An error field inside a success payload reads as success: the agent moves on, believing the old memory is retired, and the store now serves two contradictory answers to the same question. An explicit tool error makes the agent deal with it. The new memory is still kept - its content is valid knowledge, and re-writing the same name is a lossless in-place update, so fixing the target and retrying is safe.

Session binding and scope

Enforced in internal/mcp. Concepts: Projects & scope.

Never read project from tool args directly

Route it through resolveReadScope (nothing to infer → global) or resolveWriteScope (nothing to infer → errNoScope). Both apply validateProjectArg.

validateProjectArg is the path-traversal defense, and the reason it cannot be skipped is specific: a project slug becomes a directory under memory/ and notes/. The data-dir boundary check alone does not catch ../notes/_global - that slug cleans to a path inside the data dir, just in the wrong tree, where one item can clobber another's files.

A durable create uses resolveWriteScope, because it fails closed

Reaching for resolveReadScope on a write silently lands the item in the global scope. Nothing errors, nothing warns; a project's private knowledge is simply now in front of every agent on the machine.

Failing closed is the whole point: with no bound session, no ambient session, and no explicit project, the write is rejected as ambiguous and the agent must choose. project: global says it on purpose.

resolveWriteScope registers the slug it is given

A durable create into an unregistered slug used to write the file and its index row while leaving the projects table untouched: the project existed for the write, yet was absent from project_list and the console until some unrelated path (a session_start in a mapped repo, map-repo, an import) happened to backfill the row.

That gap made the fail-closed error above unanswerable in practice. An agent told to "pass project=<slug>" had no cheap way to learn whether an unmapped slug would be rejected, and an agent that cannot tell a new-project write from a broken one picks project=global - the single scope that reaches every project's briefing. The guard calls EnsureProject on the named slug, which is what lets the tool descriptions and the scope error promise that a new slug creates its project. The guidance and the behavior are the same fix; either alone is a lie.

A typo'd slug is registered too. That is the same orphan made visible rather than a new failure: the typo already created a scope on disk, and a row the console lists is where a wrong one can be noticed and merged.

Precedence, and no guessing

Scope resolution
1Explicit project
2Bound session project
3Sole unambiguous ambient
Resolution stops at the first available scope; ambiguity is an error, never a fourth fallback.

Ambient sessions spanning more than one project are errAmbiguousScope, not a guess. Inheriting the machine-latest ambient in that situation is exactly how a write bleeds into a concurrent agent's project - and the agent that wrote it will never see the mistake, because from its side the call succeeded.

Only session_start binds a connection

Bindings are keyed by the MCP client session id. They evict on session_end and via an opportunistic sweep.

A lost binding does NOT error

It degrades to the ambient fallback. Never assume the binding you started with is still there.

Stamp provenance with s.boundSession(ctx)

Not with the raw binding.

A new tool must be in registerTools AND bump ToolCount

Otherwise doctor fails its tool-count assertion. See Contributing for the full wiring, which also includes Catalog().

FTS5 and LIKE escaping

Enforced in internal/store. Concepts: Recall.

User text reaching MATCH goes through ftsQuery

ftsQuery (in fts.go) splits on non-alphanumeric runes, drops single-rune tokens, quotes each remaining term, and ORs them. Never build a MATCH expression by concatenation.

The concrete failure is small and total: FTS5 reads - as an operator, so a query for chroma-boot-race handed to MATCH raw is parsed as a subtraction - and finds nothing, or the wrong thing, without erroring. Quoting turns it into three literal terms, which is what the user meant.

ftsQuery returning "" means "no usable token"

Callers treat that as no results - never as an unfiltered query. The distinction matters because the empty string is the natural input to "no filter": a caller that forwards it returns the entire corpus in response to punctuation.

User text in LIKE goes through escapeLikePrefix

It escapes \, %, and _, in that order, with ESCAPE '\'. The order is not cosmetic - escape the escape character last and you double-escape the escapes you just added.

Never LIKE-escape a value compared with =. An equality comparison has no wildcards, so escaping only corrupts the literal being matched.

LLM degradation: remote vs local

Enforced in internal/llm and internal/retrieve.

The single distinction that organizes this whole area: did a provider answer badly, or did we fail to ask?

Sentinel Meaning Response
ErrUnavailable The provider could not be reached. DEGRADE
ErrAuth The provider rejected the key. DEGRADE
ErrRateLimited The provider throttled us. DEGRADE
ErrConfig The request was never built. SURFACE

Remote errors degrade

The provider answered badly or not at all, nothing in this process is wrong, and the condition may clear on its own. recall drops to lexical-only, which is honest: fewer results, from a leg that still works.

ErrConfig surfaces

No provider was contacted. No retry helps. It will not clear.

Degrading here would trade one loud failure for quietly worse recall for the life of the daemon - with nothing anywhere to tell the owner that semantic search had stopped. A store that silently searches half as well is worse than one that says it is broken.

Classify at the do call sites

Use doErr(op, err). Never hand-wrap a request-build failure as ErrUnavailable; that is precisely the reclassification the taxonomy exists to prevent.

Why base_url is validated at construction

NewEmbedder and NewChatClient are the single construction points, and both validate base_url there, because url.Parse accepts a bare host: "api.openai.com/v1" parses fine, builds a perfectly valid request, and only fails inside Do as an opaque transport error - indistinguishable from an outage.

So the configuration typo would arrive dressed as ErrUnavailable, degrade silently, and never be found.

That validation is exactly why ErrConfig should be unreachable at request time. Which is exactly why it must not hide when it is reached: an unreachable error that occurs is a defect, and defects should be loud.

DedupHint and files.embedItem swallow every embed error on purpose

Leave them. Dedup is advisory and must never block a memory_write; indexing is best-effort with a hash-retry (a failed embed clears the recorded content hash so the next reconcile re-indexes and tries again). These are not oversights that survived review - they are the two places where the "no fake results on error" rule is deliberately traded for "never block the agent".