v3.2.1
3.2.1 - 2026-05-28
Fixed
- TOML back-compat shim for
endorsement_debt_ratio_threshold(T9).
3.2.0's T1 rename of the[behavior] endorsement_debt_ratio_threshold
key tocold_endorsement_ratio_thresholdshipped without an alias,
so a user upgrading from 3.1.x with the old key in their config
silently lost the threshold:
behavior_raw.get("cold_endorsement_ratio_threshold", 0.0)fell
through to the dataclass default and the bucket reverted to the
strict "explicit == 0" check, dropping the loosened-ratio behaviour
the user had configured. No warning fired — the misconfiguration
was invisible. T9 adds a shim insideload_config(before
BehaviorConfigconstruction) covering four cases: (1) old key
only → populate the new key with the legacy value and emit a
one-shot DEPRECATION warning naming both keys plus the resolved
path; (2) new key only → no-op (post-3.2.0 happy path); (3) both
present → the new key wins and a STRONGER one-shot warning instructs
deletion rather than rename; (4) neither → no-op, dataclass default
applies. The one-shot guard is keyed on(resolved_config_path, key_kind)and mirrors the_DIVERGENCE_WARNED_ROOTSdiscipline
instore.py: a long-lived server that rereads config on signal
doesn't spam the log, but two distinct configs in the same process
each get their own warning. Path resolution falls back to the
unresolved path onOSErrorso the guard still works if the config
moved betweenopen()and warn-time. This supersedes the
"silently fall back to the default0.0" guidance in the 3.2.0
T1 entry above — the deprecation window is now explicit and
loud-on-load instead of silent-on-rollup. Store.restoreunder-lock recheck (W7). Mirror of the W1
tombstone()recheck. Pre-W7Store.restorewalked
_find_tombstone_path_for_idand_find_path_for_idunlocked,
then acquired_locked(tombstone_path)and called
frontmatter.load(tombstone_path)without a recheck. Two
restorers of the same id, or a restore racing with a concurrent
prune_tombstones, would both pass the unlocked finds; the loser
hit a bareFileNotFoundErrorfrom insidefrontmatter.load—
caught by an inline arm and re-raised asMemoryNotFoundError,
but with no symmetric "raced with" message and no recheck for the
case where the active record was re-created in the restore window
(silent_atomic_write_postclobber). Fix adds two under-lock
rechecks mirroring W1's discipline: (1)_id_still_at_path(tombstone_path, memory_id)— the tombstone still carries the expected id (catches
the parallel-restore / prune race and the extremely-unlikely
tombstone-stem-reuse edge); (2)_find_path_for_id(memory_id) is None
— defensive against the narrow case where a parallel restore
unlinked the tombstone AND a separate path re-created an active
file at the predicted slug-suffix-determinedactive_pathbetween
the pre-lock active check and the lock acquisition. Either recheck
failing raises a typed exception (MemoryNotFoundError/
NotTombstonedError) with a "raced with concurrent restore [or
prune]" hint, matching W1's find-time pre-lock fallback message
shape. Thememory_restorehandler gains anOSErrorarm mirroring
W1'smemory_removeso genuine disk-level failures during the
restore write / unlink (EIO, ENOSPC, EACCES, …) surface as
structuredValueErrorinstead of leaking as bareOSError.config.load_configfirst-run default-config write is now atomic
(Q29). The first-run writer used
config_path.write_text(DEFAULT_CONFIG, encoding="utf-8"), which
leaves a truncated TOML on power loss / process kill mid-write;
the next run would see the malformed config and crash at
tomllib.load, turning a single bad shutdown into a hard-block
first-run experience. Now routes through_fsutil.atomic_write_bytes
(the F5/F6 helper), same shape as the F5init.pyMCP-client-config
and F6sync.py.gitignoremigrations: plain bytes payload, no
special mode bits, no privacy bar — the helper's chmod-after-rename
posture is the right fit.bettermemory export -o PATHis now atomic (Q29). The CLI
export writer usedout_path.write_text(text + "\n", encoding="utf-8"),
which leaves a truncated JSON on power loss / process kill
mid-write. For a CLI intended for scripted backup
(bettermemory export -o backup.json), a half-written file is the
exact failure mode the user is trying to guard against. Now routes
through_fsutil.atomic_write_bytes, completing Queue #29's
simple-bytes Branch A targets (F5 init.py, F6 sync.py, Q29 config.py,
Q29 cli/export.py). The remaining deferred sites
(_atomic_write_post,episodes._write_path,
semantic.flush_persistent_cache,events._compress_rotating)
all need fchmod-before-rename, which the current helper contract
doesn't cover.Store.mark_verifiedrace shape — attestation overwrite (W8).
Mirror of the W2Store.updateCAS pattern, applied to the
verification path. Pre-W8 two agents callingmemory_verifyon the
same id with disjoint attestations (e.g. agent A spot-checking path
#1, agent B spot-checking path #2 simultaneously) would silently
last-write-wins on theverified_paths/verified_commits/
verified_versionslists, because bothmark_verifiedcalls read
the same on-disk snapshot and the second write clobbered the first
without a CAS check. The REPLACE-not-append semantics on those
lists (by design — the event log is the audit trail) made the
silent loss especially nasty: one of the two agents' attestations
vanished without surface. Post-W8Store.mark_verifiedgains an
optionalexpected_last_verified_atsnapshot fingerprint plus a
check_expected=Trueopt-in; under the lock, after the C2 recheck,
the on-disklast_verified_atis compared to the caller's
snapshot. On mismatch raiseConcurrentUpdateErrorcarrying the
on-diskupdated(kept uniform with W2's exception contract — the
caller's rebase action is the same: re-fetch via memory_show and
retry). Thememory_verifyhandler loads its snapshot first and
opts in; legacy direct-store callers (the web UI verify form, the
no-arg slide-the-timestamp-forward use cases, the existing tests)
keep the back-compatcheck_expected=Falsedefault. MCP response
shape on CAS failure exactly mirrorsmemory_update's W2 stale
payload:{"status": "stale", "memory_id": ..., "current_updated": ..., "hint": ...}. Fingerprint choice:updateddoesn't move on a
verify (orthogonal to content edits) so checking it would never
catch the verify-vs-verify race;last_verified_atis the field
that always moves on a successful verify, so it's the cheapest
correct fingerprint.bettermemory export -o PATHraises on a missing parent
directory again (Y1). The Q29 atomic-write migration above
inadvertently changed user-facing semantics: pre-3.2.1 the CLI
used a bareout_path.write_text(...), which raised
FileNotFoundErrorwhen--output's parent directory didn't
exist; routing through_fsutil.atomic_write_bytes(whose
mkdir(parents=True, exist_ok=True)is intentional for
fresh-install callers likeinit.pyunder~/.configand
sync.pyunder a fresh sync root) silently created the parent
tree instead, burying abettermemory export -o /typod/path/backup.json
backup at an unintended location with no error. A pre-check in
_cli_exportrestores the 3.2.0 loud-error contract for the
export caller while leaving the helper's auto-mkdir intact for
its other callers. A bare filename (-o backup.json, parent
Path(".")) still works since the cwd always exists.
Internal
EndorsementDebtRow→ColdEndorsementMemoriesRoweval-module
rename (T8). 3.2.0's T1 renamed the publicendorsement_debt
field tocold_endorsement_memoriesacross the health surface
but explicitly deferred the eval-module internal classes
(EndorsementDebtRow,endorsement_debt_rows,
endorsement_debt_total) as a separate API. That left drift
between the renamed public JSON key (already
cold_endorsement_memories) and the still-old internal Python
identifiers that filled it. T8 closes the drift:
EndorsementDebtRow→ColdEndorsementMemoriesRow;
EvalReport.endorsement_debt_rows→cold_endorsement_memories_rows;
EvalReport.endorsement_debt_total→cold_endorsement_memories_total;
function-localdebt_rows/debt_total→cold_rows/cold_total;
comments/docstrings/render-text header ("Endorsement-debt memories"
→ "Cold-endorsement memories") plus CLI--min-retrievalshelp
text;TestEndorsementDebt→TestColdEndorsementMemoriesand
test_endorsement_debt_section→
test_cold_endorsement_memories_section. Internal-only rename:
the MCP wire shape and eval JSON output key were already renamed
by T1; this only touches Python-level identifiers and CLI text.
No alias — the eval module's only external consumers are its
tests and the CLI wrapper, both migrated. Remaining
endorsement_debtsnake_case sightings are confined to T9's
back-compat TOML alias inconfig.pyand its tests, and
explanatory DESC-string regression-guard assertions in
test_server_v12_features.py/test_health.py.flock_exclannotation tightened toGenerator[None, None, None]
(F15). The@contextlib.contextmanager-decoratedflock_excl
in_fsutil.pywas typed asIterator[None], which loses
send()/ return-T type information thatGenerator[YieldT, SendT, ReturnT]preserves. Explicit three-arg form for Python
3.11+ compatibility (the single-argGenerator[T]shorthand is
3.13+ only). Purely type-annotation; no runtime impact._flock_windowsannotation matches F15 (F15-followup).
Completes the F15 pattern sweep._flock_windowsis the generator
helper used viayield from _flock_windows(...)inside
flock_excl's Windows branch; same shape, same fix.Iterator
is now unused in_fsutil.pyand leaves the imports too. Purely
type-annotation; no runtime impact.docs/api.mddocuments the W2/W8 stale + W7 race-loss surfaces
(R3). Three tools landed structured race-loss / stale responses
in 3.2.0–3.2.1 that the API contract documented only the happy
path of. Append a paragraph to each so multi-agent callers learn
about the{"status": "stale", ...}shape onmemory_update
(W2) /memory_verify(W8) and the structured
ValueError("raced with ...")translation onmemory_restore
(W7); W8 also calls outlast_verified_atas the snapshot
fingerprint (notupdated, since verify is orthogonal to content
edits). Documentation only._fsutil.atomic_write_bytesdocstrings refreshed post-Q29
(Y2). Module + function docstrings claimed two callsites and a
forward-looking promise to migrate_atomic_write_post+
episodes._write_path; reality at HEAD is that Q29 deferred those
two privacy-critical writers (they need fchmod-before-rename,
which the helper doesn't support) and instead migrated
config.py's default-config writer andcli/export.py's-o
output writer. Switched to caller-shape framing ("plain-bytes
payload, no privacy-0o600 requirement") so the prose stops
drifting on every new caller, and rewrote the Q29 caveat to
reflect what shipped. Docstring only.- T1 rename completed in user-facing prose (Y10+Y11+Y12).
3.2.0's T1 finished renamingendorsement_debt→
cold_endorsement_memoriesin code, MCP wire keys, DESC strings,
and the eval render header, but prose docs still taught the old
branding. SweptREADME.md,docs/ROADMAP.md, anddocs/eval.md
to the new vocabulary. Historical CHANGELOG / api.md references to
the old key are preserved on purpose — they document the 3.1.x →
3.2.0 wire-shape break and the T9 TOML back-compat shim. Docs only. Raises:blocks added toStore.update/mark_verified/
tombstone(Y9). Symmetric with W7'sStore.restore.
Documents theConcurrentUpdateError/MemoryNotFoundError/
TombstonedErrorrace-loss paths in structured form so IDE
introspection matches what the type checker already reads from the
bodies. Docstring only.- Test-scaffold accuracy fixes (Y3+Y6+Y8). Three small
test-only fixes batched:test_store_locking.py'straced_locked
annotation upgradedIterator[None]→Generator[None, None, None](completes the F15 sweep on the test side);
test_concurrency.py's multi-process restore test gains a
closed-set assertion on the three known loser-message shapes
(the prior version was not a true W7 regression guard); and
test_config.pygainstest_load_config_falsy_old_key_emits_warning
pinning that an explicitendorsement_debt_ratio_threshold = 0.0
still fires the deprecation warning and migrates (presence
triggers, value doesn't), with a corrected docstring on the
absent-key sibling. - Test pinning the BOTH-keys legacy-alias one-shot guard (Y4).
_apply_legacy_endorsement_debt_alias's+bothguard tuple is
logically distinct from the old-only one; a sibling test loads a
both-keys TOML three times and asserts exactly one BOTH warning
fires, guarding against a regression that collapses the suffix or
cross-suppresses between branches. Test only. - Test covering
Store.restore's active-recheck branch (Y7).
The existing W7 monkeypatch test exercised the tombstone-recheck
(which fires first) rather than the active-recheck
(NotTombstonedError) branch it claimed to. Added a dedicated
test that patches_find_path_for_idonly on the under-lock call
site so the active-recheck runs deterministically, and tightened
the original test to the exception it actually raises
(MemoryNotFoundError). Test only. eval.pycomment justifying the ENDORSEMENT vs
COLD_ENDORSEMENT name choice (Y5). Expanded the
literal-duplication comment to also explain why the eval-side
identifier omits thecold_bucket prefix that health carries:
the eval output nests undercold_endorsement_memories, so the
bucket scope is already supplied and the parameter prefix would be
redundant — whereas health's flat-kwarg path needs it.
Comment only.- Tests pinning cross-branch independence of the alias guards
(Y4-fu). Y4 pinned same-branch repeat-suppression but reset the
guard at entry, leaving the cross-branch transition (old-only on
path P, then both-keys on the same path with no reset) untested.
Added both directions, asserting each branch's warning fires fresh
and both guard tuples are present, so a regression that collapsed
the+bothsuffix into a shared tuple can't cross-suppress and
still pass. Test only. - Multi-process W7 race test rendezvous (Y6-fu). Added a
file-based rendezvous (per-worker ready-marker + a shared
go-marker the parent touches once all workers are ready) so all
losers route through the W7 under-lock recheck and carry the
"raced with" hint, letting the strict assertion the threaded
variant already uses hold for the multi-process variant too —
upgrading Y6's pragmatic closed-set concession. Test only.
Full diff: v3.2.0...v3.2.1