Repository navigation
Releases: fjwood69/mori
Release list
v2.3.12
v2.3.12 — Security and correctness hardening
Upgrade check for clients. Send the API key in the
X-Api-Keyheader only: a key in the
URL (?api-key=/?api_key=) is now refused with 400. Every request on an MCP session must
carry the key that opened the session. Claude Code, Cursor, Antigravity, Cline and opencode
configured with a header already do both.
Measured before this release, in api mode: an MCP session opened with a read key, followed by
a request with no key at all, wrote a file on the server through memory_export. A session id
was a bearer credential that never expired and skipped the rate limiter. This release closes that
and the related file-system and authority gaps found while auditing it.
Behaviour changes
- Export never writes a server-side file for a single memory.
memory_export(name)returns the
content; theoutput_pathargument is gone (passing it is an error, not a silent ignore).
memory_export_all()takes no directory: it writes to the server's fixed<data dir>/exports/
and needs thedreamerrole. Exports contain active memories only — deleted ones are excluded. - Import reads only
<data dir>/imports/and needs thedreamerrole; thesource_dir
argument is gone. Each imported write is attributed to the calling key (previously the generic
actorimport), and atier:in the frontmatter is judged by the tier rules like any other
write — withMORI_TIER_ENFORCE=enforcean import can no longer land canonical memories. - API keys in the URL are refused (400) on every path, including the open ones. The access log
redacts such values. - MCP sessions are bound to the key that opened them. Every request on a session must carry
that key (else 401); identity and role are decided per request; the rate limiter counts session
requests. Sessions expire after 12 hours idle or 7 days in total (MORI_SESSION_IDLE_TTL_S,
MORI_SESSION_MAX_AGE_S); an expired or unknown session gets 404 and the client re-initialises.
In open mode (no keys configured) sessions bind toanonymousand need no key. memory_protectsets rather than toggles.memory_protect(name)protects,
memory_protect(name, protected=false)unprotects; a repeated call no longer flips the flag (a
change on SQLite). Unprotecting clears the protected domains;domains=[]clears them; omitting
domainskeeps them. A name with no active memory is reported not found (Postgres previously
reported success). Both are audited (op = protect/unprotect) with the calling key.- Rolling back a protected memory is refused on both backends, with the steps to follow. In
v2.3.11 SQLite queued it instead. "Protected" means the flag or a tag matching
protected_tag_prefixes— on the live memory or on the version being restored. A tag-prefix
protection cannot be lifted withmemory_protect; restore the content with a reviewed write. - Postgres honours
protected_tag_prefixeson ordinary writes, as SQLite always has: a write
whose tags match one is queued for review instead of applied. In api mode only adreamer-role
key bypasses this (and the same rule now applies on SQLite, where a key merely named like a
trusted_clientsentry used to bypass it). Host mode keeps thetrusted_clientslist. The dream
pipeline and standards import are unaffected. Check your prefixes: a prefix such asinfra
also matchesinfrastructure. - Memory names are validated at the write chokepoint:
^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$and
no... Anything else is refused and counted (mori_write_rejections_total{reason}). Names the
dream and ingestion derive from free text are normalised to this form; a name that was already
valid is unchanged. - Roles on every state-changing surface.
write:POST /api/events,/api/events/raw,
/api/precompact,/api/dream/run,/api/git/ingest, and themori_ingest,dream_run,
consult_advisor,nats_pubandmsg_sendtools.dreamer:key_generateand
standards_reload.memory_exportis explicitlyread. - Consult calls are not retried by the OpenAI SDK (
max_retries=0for the advisor in both
provider modes). A retried consult re-sent a call its caller had already abandoned — one ran for
43 minutes over three attempts. SetMORI_ADVISOR_MAX_RETRIESto restore retries. Fast and dream
calls keep the SDK default. - SQLite
approve(#71, #76). A pending write is marked approved only when the memory was
actually written, and an approved change to a protected memory is applied rather than queued
again — as on Postgres. UnderMORI_ANATOMY_ENFORCE=enforcean incomplete pending write stays
pending and the message says so. An approved write now lands at the tier recorded on the pending
row. A second queued write for the same name updates the open pending row instead of failing.
Fixes
- The four Postgres variants of the soft-delete/restore tests never awaited the store, and CI's
Postgres job did not run that file (#70). Both fixed. - The dream's per-run write outcomes (accepted / rejected / downgraded / skipped / error) are
persisted and exported asmori_dream_last_run_writes{result}, so a rejected write from a
scheduled run is visible on/metrics.
Notes
- No database migration.
/metricscarries no key names (tested). - New metrics:
mori_mcp_sessions,mori_session_rejections_total{reason},
mori_url_key_rejections_total,mori_write_rejections_total{reason,actor},
mori_dream_last_run_writes{result}.
v2.3.11
v2.3.11 — Restore the recovery path; supersession is report-only by default
SQLite users: the dream has written no memories since v2.3.0 (22 June 2026). On the default
SQLite backend every dream write failed on an argument the SQLite store did not accept, and each
failure was logged and skipped, so dreams ran but produced nothing. This release fixes it; your
next dream run writes again.
Version history, rollback and undo were dead on the Postgres backend, and rollback was broken on
both backends. A database whose rows were imported from SQLite could also be left with sequences
behind the imported ids, which made every supersession write fail silently. This release restores
the recovery path, repairs that class of sequence automatically, and makes supersession
propose rather than apply by default until a reviewer has the tools to judge it.
Why report-only: in one long-running deployment, three of the five supersession verdicts the
scanner produced after its last working period named a "new" memory that had never been written —
a candidate still waiting for human review. The scanner was mostly acting on memories that did not
exist. This release fixes that (below) and makes supersession a reviewed decision by default.
Behaviour changes
- Supersession is report-only by default. A
SUPERSEDESverdict from the contradiction scan
now queues aneviction_queuerow (reasonsupersession_proposed) for review and does not
hide the older memory. SetMORI_SUPERSESSION_MODE=writeto apply supersessions automatically;
any other value (includingtrue,yes,1) means report-only. The effective mode is logged at
start-up and by each scheduled dream run. - Rollback goes through the write path. It now records a version snapshot of the state it
replaces, writes an audit row (op = rollback), and obeys protection, tier and anatomy rules.
Rollback restores content only: it never changes a memory's tier.- Rolling back a canonical memory requires the
dreamerrole. - Rolling back a protected memory differs by backend: Postgres rejects it with the steps to
follow (unprotect, roll back, re-protect — each audited); SQLite queues it as a pending write. - With
MORI_ANATOMY_ENFORCE=enforce, a rollback whose content fails the anatomy check is queued
for review, except when adreamerrestores an exact stored version of the active memory; that
bypass is audited (reason_code = anatomy_bypass_rollback). - A version from an earlier incarnation of a reused name is refused where the version records
which incarnation it belongs to (memory_id, set for every version written from v2.3.11).
Older versions with nomemory_id— those of a name that was deleted and re-created before
this release — are not checked and can still be rolled onto the live memory.
- Rolling back a canonical memory requires the
- A Postgres write is one transaction. Version snapshot, upsert and audit row commit together;
if the audit row cannot be written, the write fails instead of succeeding unaudited. - Deletes and restores are audited in the same transaction as the change, on both backends.
Thememory_deleteMCP tool was previously unaudited. - Canon export excludes superseded memories.
Fixes
- Postgres now writes
memory_versionson every update (it wrote none before), pruned to the
newest 20 per memory.memory_history,memory_diffandmemory_rollbackwork again. - Rollback no longer crashes: a
KeyErroron Postgres and aTypeErroron SQLite. - The contradiction scan only considers memories that were actually written. A candidate that was
skipped, downgraded, or routed to human review (ingestion of canonical candidates) can no longer
supersede existing memories. - On the SQLite backend the dream wrote no memories since v2.3.0 (a
provenanceargument the
SQLite store did not accept); it writes again. approveunderMORI_ANATOMY_ENFORCE=enforcecould hang indefinitely on Postgres; it no longer
does.- A restore that renames the row (name already taken) now carries the row's version history with
it, and tells you if the restored memory is still superseded. - A failed supersession write is no longer counted as a classifier error.
New
- Boot-time sequence repair (Postgres). On every start, after migrations and under the
migration lock, any column-owned sequence whose next value is not above its column's maximum is
raised to it (never lowered), logged at WARNING and counted inmori_sequence_repairs_total. - Review tools (role
dreamer, MCP and REST):memory_unsupersede(name, note)/POST /api/memories/{name}/unsupersede— clears the flag,
resolves the queue row, audits it. An undone pair can be proposed again but never applied
automatically.memory_supersession_decide(queue_id, decision, note)/
POST /api/eviction/{queue_id}/decide— apply or dismiss a proposal. A dismissed pair is
never re-classified.memory_reviewlists open proposals with their queue ids and counts pairs by state.
- Migration 17 (both backends):
memory_versions.memory_id(versions keyed to the row; filled
for existing versions only where the name has a single incarnation — others stay name-keyed),
andeviction_queue.counterpart/resolutionwith a unique index on open supersession pairs. - Metrics:
mori_supersessions_total{mode,result},
mori_dream_last_run_supersessions{result}(persisted per run, so scheduled dreams in a
separate process are visible),mori_sequence_repairs_total{table}.
Upgrade notes
- Supersessions stop being applied automatically after upgrading. Review proposals with
memory_reviewandmemory_supersession_decide, or setMORI_SUPERSESSION_MODE=write. - If you imported a SQLite database into Postgres, the first start may log
SEQUENCE-REPAIR
warnings; that is the repair working. A repair on any later start means another out-of-band
import or restore has happened.
v2.3.10
v2.3.10 — Postgres write path: stop re-encoding JSONB; orphan scan never deletes
A memory UPDATE on the Postgres backend JSON-encoded protected_domains as it came back from
asyncpg — a str, since no JSONB codec is registered — so every update nested the array one
JSON-string level deeper, roughly doubling it. A row updated on every deployment reached a ~1 GiB
bind parameter and exhausted a 2 GB host's memory. This release fixes the encoding, repairs
existing data and makes the bad state impossible to store.
-
One decoder for JSONB arrays read back on the write path (
protected_domains,
origin_session_ids,origin_clients). Legacy nesting is unwrapped (up to 64 levels, at most
1 MB decoded), logged at WARNING with memory, column and depth, and counted in
mori_jsonb_unwrapped_total{column}(pre-initialised at zero). A value that does not decode to
a list returns aREJECTEDwrite result instead of raising. Any row that gets updated is
rewritten in the correct form. -
Migration 16 (Postgres only).
- A
protected_domainsvalue stored larger than 4 KB is replaced with[]without being read —
reading it is what exhausts memory. - String-nested values in
protected_domains,tags,origin_session_idsand
origin_clientsare unwrapped; anything that does not end as an array becomes[]. CHECK (jsonb_typeof(col) = 'array')constraints are added to those four columns (added
NOT VALID, then validated;lock_timeout5 s).
Fresh installs get the constraints from the schema. Once the constraints exist, an encoding bug
fails the write instead of silently growing it. - A
-
scan_orphanson Postgres no longer deletes (behaviour change). It matches the SQLite
backend: non-canonical (tier IS NULL OR tier != 'canonical'), unprotected, active memories
that have been retrieved before but not within the window are queued ineviction_queue
(reasonorphan) for review. Never-retrieved memories are not orphans. Previously a
non-dry-run scan permanently deleted every unprotected working memory not retrieved in the
window, including ones never retrieved. Like SQLite, repeated scans do not de-duplicate: a
still-stale memory is queued again on each non-dry-run scan. -
approve(Postgres) marks a pending write approved only if the canon write was accepted;
a rejected or downgraded write leaves it pending. -
Deployment contract: the probe memory's write must succeed (a non-2xx write now fails the
contract instead of skipping), and on Postgres the store must show that write landed in this
run (updated_atat or after the store clock read just before it) withprotected_domains
exactly[]. -
Start-up refuses to serve if migration 16 fails: the migration rolls back and the server
does not start on an unmigrated schema.
v2.3.9
v2.3.9 — Fail-closed one-word classifiers
The gateway route behind the fast VK began serving a reasoning model. With the
classifiers' small max_tokens it spent the whole budget thinking and returned empty
content with finish_reason=length. The freshness check mapped any reply other than
exactly NO / STALE to fresh, so it stamped canonical memories fresh on zero answer
tokens; the contradiction scan went silently blind (an unparseable verdict logged nothing).
Model routing stays in the gateway by design, so the fix makes the code correct for any
served model.
- Fail-closed parser (
utils.parse_one_word_verdict), shared by the freshness check
and the contradiction scan. A reply counts only if it is exactly one allowed word after
end-only normalisation (whitespace, one trailing., surrounding quotes / backticks /
asterisks), with any remaining non-ASCII character rejected before case comparison
(str.upperfolds look-alikes such asſ,stinto allowed tokens). Anything else —
empty, a reasoning leak, two words — is an error: freshness writes neither
freshness_statusnorfreshness_checked_at(the row stays immediately re-eligible), and
the scan writes nothing. The intake assessor was already fail-closed (structured output). - Reasoning off for classifier calls only. The three classifier sites send
reasoning_effortfromMORI_CLASSIFIER_REASONING_EFFORT: unset →"none"(a reasoning
model answers in a few tokens, at the answer's own length, within the existing budgets);
set but empty → field omitted (escape hatch for a routed
model that rejects it); anything else → that value.BifrostClient.consultgains a
reasoning_effortargument with no environment default, so advisor, dream and vision
calls are unaffected. No automatic retry without the field — a failing route shows up in
the metrics instead. MORI_FRESHNESS_ON_BRIEFnow defaults tofalse. The check asks "is this still
accurate?" with no evidence; with reasoning off its verdict on one memory was measured as
a coin flip at temperature 0. A wrongstaleis terminal (never re-checked) and a wrong
freshis surfaced to API consumers, sounknownis the honest default until freshness
is grounded in evidence. Opt back in with exactlyMORI_FRESHNESS_ON_BRIEF=true
(case-insensitive) — any other value, includingyesand1, now means off. Re-enabling it
before that redesign re-introduces guessed verdicts and re-poisons rows the release reset.- Contradiction scan hardening. A memory is never compared with — or superseded by —
itself: its own active row is excluded by primary key (an ingest written straight to the
canonicaltier matched its own name prefix; the blind scan had masked this).
memories.nameis unique only among non-deleted rows, so candidates now exclude
soft-deleted rows and the supersessionUPDATEis keyed by id. Each supersession (the
UPDATEand its eviction-queue row) is atomic: a savepoint on Postgres — so one failed write
no longer aborts the scan's transaction and silently rolls back supersessions already
counted — and a rollback on SQLite, so a half-applied pair is never committed by the next
one. Candidates are ordered by id (deterministic underLIMIT 5). Scan failures log at
WARNING instead of DEBUG. - Metrics:
mori_classifier_verdicts_total{site,outcome}—site∈
freshness / contradiction / intake;outcomeis the parsed verdict (lower-case),
unparseableorerror— andmori_llm_truncated_total{vk}for replies that stopped at
max_tokens(a dashboard signal; routine for some advisor / dream calls). Every series is
created at zero on import, so the first burst of failures after a restart is visible to
increase(). A parsedSUPERSEDESwhose write then fails is counted twice — once as the
verdict and once aserror— so the unparseable+error share is not "the fraction of calls
that failed".
v2.3.8
v2.3.8 — /consult and dream: observability
A consult sat pending for 25+ minutes with nothing in the advisor to say whether it
was queued, in flight, or already answered — the only evidence was in the gateway's own
log. Every outbound LLM call, the consult slot queue and each dream run are now logged
and measured.
- Lifecycle logs (key=value, greppable by id):
consult.created→llm.slot_queued
(only when waiting) →llm.slot_acquired→llm.send→llm.recv/llm.fail→
consult.done/consult.error, plusllm.slot_timeout,llm.call_timeoutand
dream.run outcome=… written=… elapsed_s=…. Consult lines carry thejob_id; LLM
lines carry a per-callcall_id, the VK, the serving provider, latency, output tokens
and finish reason. The model is logged twice on purpose:model_requestedon send (in
Bifrost mode only a routing key — gateway rules may rewrite it) andmodel_servedon
receive, from the response. - Gateway join key. Each HTTP attempt sends
x-request-id: <call_id>(retries
<call_id>.r<n>). Bifrost stores it aslogs.idand logs its own server-side
fallback hops withparent_request_idset to it, so
WHERE id LIKE '<call_id>%' OR parent_request_id LIKE '<call_id>%'returns the whole
chain. Ids are per attempt because Bifrost keeps one row per id: the OpenAI client's
automatic retries resend identical headers and would otherwise lose their rows
(measured). - Metrics on
/metrics:mori_llm_call_duration_seconds{vk,outcome,provider}
(histogram, buckets to 900s),mori_llm_output_tokens_total{vk},
mori_llm_inflight{vk},mori_llm_oldest_inflight_seconds,mori_llm_slots_active,
mori_llm_slots_queued,mori_llm_slot_wait_seconds, and a dream last-run set
(mori_dream_last_run_duration_seconds,…_timestamp_seconds,
…_memories_written,…_outcome{outcome}). The dream summary is persisted in
dream_statebecause the scheduled dream runs in a separate process
(python -m mori_advisor.dream_job) whose in-memory metrics would never reach the
server. Labelled byvk, not feature: dream and ingest share thedreamVK, so
per-feature dream timing comes from the last-run summary. - Bounded slot wait. A consult now waits at most
MORI_LLM_SLOT_WAIT_TIMEOUT
(default 600s) for one of the 6 LLM slots, then fails with
LLMSlotUnavailable: no LLM slot free after 600s (6 of 6 in use)instead of reporting
pending. The wait was already bounded by the slot-holders' ownLLM_CALL_TIMEOUT,
but it was indistinguishable from a normal in-flight job. - Caller-timeout message now reads
TimeoutError: no response within 900s; the call may still complete at the gateway(was a bareTimeoutError). - Fix —
mori_dream_undreamedwasevent row count − watermark id, which reads 0
once events are pruned (row count ≪ max id). It now uses
count_events_since(watermark), as/dream --statusalready did. Measured in UAT:
gauge 0 while 20,804 events were waiting. - Fix — deploy examples.
deploy/soloanddeploy/team.env.exampleset
MORI_BIFROST_BASE_URL, which the client never reads (it readsMORI_BASE_URL), so
a non-default gateway URL was silently ignored. Renamed. - Removed the OpenTelemetry
mori_consult_duration_ms/mori_dream_duration_ms/
mori_consult_tokensinstruments: defined, never recorded, and not in the scraped
registry. - Known, not changed here: the OpenAI client retries twice by default, so a hung
provider holds an executor thread for up to ~3 ×MORI_BIFROST_TIMEOUT— after the
caller has already given up atLLM_CALL_TIMEOUT. Now visible as
mori_llm_oldest_inflight_secondsexceedingLLM_CALL_TIMEOUT.
v2.3.7
v2.3.7 — /consult: raise file-attachment truncation limits
MAX_FILE_SIZE50KB → 125KB,MAX_TOTAL_FILE_SIZE200KB → 500KB
(mori_advisor/main.py). Same 25% per-file/total ratio as before; sized so
4 files at the per-file cap fit exactly in the total budget. Prompted by
--fileconsults on the production advisor model (kimi-k2.7-code,
large context window) needing more than 200KB of combined attached source
for realistic multi-file reviews.- Applies identically to both file-read paths (
_read_filesfor server-local
paths,_blocks_from_file_contentsfor the client-supplied bodies the
/consultskill actually uses) — they share the same two constants.
v2.3.6
v2.3.6 — /consult: stop discarding completed answers
_run_llm's abandonment backstop raised from a hardcoded330sto an
env-backedLLM_CALL_TIMEOUT(default900s,MORI_LLM_CALL_TIMEOUT).
Measured 2026-07-29: successful deep consults land at 132–590s; the 330s cap
was below the normal working range, not a safety margin — five complete
answers (up to 32,768 tokens) were generated successfully and then discarded
client-side in one day. Cancellingwait_fordoes not stop the underlying
thread, so the work and its cost were incurred either way; raising the cap
stops throwing the result away, it does not create new load.BIFROST_TIMEOUTdefault raised300s→900sin bothmain.pyand
ingestion_server.py(kept identical on purpose — one-of-two-sites
divergence is the defect class this change avoids).- Job-failure messages now include the exception type
(f"{type(e).__name__}: {e}") instead of juststr(e). Every discarded
answer today surfaced as an empty"Advisor call failed: "message and was
twice misdiagnosed as a swallowed provider error — it was
asyncio.TimeoutError, which has no args, sostr(e)is empty by design.
The type name alone would have said "timeout" in one word. - Deployment note: the GCE container sets
MORI_BIFROST_TIMEOUT=600
explicitly in its Quadlet unit, which overrides the new code default. The
unit env was updated to900alongside this release — the code change alone
would not have taken effect otherwise. - Explicitly not done: capping
deepmax_tokens(32768→8192) to cut
latency — rejected on the measurement, since observed completions already
reach up to 32,768 tokens and capping would silently truncate answers
rather than speed them up.
v2.3.5
v2.3.5 — /consult: stop sending a fixed temperature
bifrost_client.py:consult()no longer sendstemperatureunless the caller
explicitly passes one — default changed from0.3toNone, and the field is
now omitted from the request entirely when unset. Some provider endpoints
(Nebius/Kimi-K3) reject any client-supplied temperature outright, causing
/consultto fail in ~600ms. Blanking rather than pegging to1.0avoids
tying the fix to one endpoint's requirement, since the routing rule fans out
across multiple vendors.main.py's/consultcall site no longer passestemperature=0.3— the one
behavioural change; every other caller (ingestion, dream, extraction) still
passes its own value explicitly and is unaffected.- Removed a dead
max_tokens < 200guard inconsult()left over from a
K2.6-specific constraint on a client that now routes to K3; verified
unreachable (the one call site using that VK always sendsmax_tokensin the
thousands). - Added
tests/test_bifrost_client_temperature.py— no prior test coverage
existed forbifrost_client.py.
v2.3.4
v2.3.3
v2.3.3 — /consult async jobs + remote file_contents
Breaking change: consult_advisor now returns a JSON job_id immediately instead of the
advice string. Poll the new consult_status tool until done / error. Deep consults can
take ~300s of Bifrost inference; holding the MCP HTTP request open was causing LB 504s.
- Async submit + poll: in-memory
_consult_jobs(single Quadlet process; wiped on
restart/CD). Background task runs prompt build +_run_llm+ capture. file_contentsparameter: client-supplied[{name, content}, ...]with the same
truncation budgets as_read_files. Preferred for remote GCE; keepfiles=for
co-located path reads.- Skill update: both
skills/consultand plugin mirror — passfile_contents, poll
consult_status(5–10s, ~10 min deep deadline). - Tests:
tests/test_consult_async.py; hardening/nonblocking/MCP stubs updated for poll.