Summary
normalize.py's newest-first re-sort is described in-code as a "self-heal guardrail", but it fails open silently: a single malformed entry anywhere in a container disables the re-sort for that entire container, with no log line, no warning, and no entry in the changes list. The protection reports success while doing nothing.
Found from an external AIPass project while fact-checking a claim about session counts. The corrupted file was a real agent's live .trinity/local.json — the guardrail had been off for days and nothing surfaced it.
The code
src/aipass/memory/apps/handlers/schema/normalize.py (verified on origin/main @ e26255d6, v2.7.13):
# Sort list entries newest-first by number (self-heal guardrail)
for container_name in ("sessions", "key_learnings", "todos", "observations"):
container = data.get(container_name)
if isinstance(container, list) and len(container) > 1:
has_numbers = all(isinstance(e, dict) and "number" in e for e in container) # L148
if has_numbers: # L149
sorted_entries = sorted(container, key=lambda e: e["number"], reverse=True) # L150
Two distinct defects:
1. Silent fail-open (L148–149). has_numbers is all(...). One entry missing the number key sets it False, the re-sort is skipped for the whole container, and nothing is recorded — changes stays empty so the normalize run reports clean. There is no logger.warning on this path.
2. Unguarded sort key (L150). has_numbers only checks that the key exists, not that its value is an int. If every entry has the key but one value is a string (e.g. "171" instead of 171), sorted() raises TypeError: '>' not supported between instances of 'str' and 'int' inside the normalize path.
Why this is worse than either bug alone
The two defects mask each other, which is how the real-world case went undetected:
- File had one entry with
number as a string and one entry with no number key at all.
- The missing key made
has_numbers False → silent skip → the string never reached sorted().
- Repairing only the missing-key entry flips
has_numbers to True, and the run then crashes on the string.
So the obvious partial fix converts a silent no-op into a hard TypeError. Repair of an affected file has to be atomic across both defect types. This was verified empirically before filing.
Impact
- Applies to all four containers:
sessions, key_learnings, todos, observations.
- These arrays are newest-first by contract, and rollover archives the tail as "oldest". With the re-sort silently off, a misordered write can put a fresh entry at the tail, where the next rollover archives it as history. That is silent memory loss — the exact failure class the guardrail exists to prevent.
- Operators have no signal. Nothing in
changes, nothing in the log, no non-zero exit.
Related: the newer guard does not heal existing files
The edit_gate newest-first check (DPLAN-0278) landed 2026-08-01 and guards new writes. It performs no retroactive repair, so any file corrupted before that date remains silently unprotected. In the case that prompted this report, the corruption was written one day before the guard shipped.
Suggested direction
Not prescribing the fix — flagging what would have surfaced it:
- Make the skip loud. When
has_numbers is False, log at WARNING (or record a changes entry) naming the container and the offending index. A guardrail that declines to run should say so.
- Validate the value, not just the key.
isinstance(e.get("number"), int) rather than "number" in e, and handle the mixed case explicitly instead of letting sorted() raise.
- Consider coercing or quarantining rather than skipping wholesale — a single bad row currently forfeits protection for every good row beside it.
- A repair path for files already in this state, given the edit_gate guard is write-time only.
Environment
- AIPass
origin/main @ e26255d6, version 2.7.13
- Reported from an external project consuming AIPass as a framework (a useful vantage — the failure is invisible from inside the affected agent)
Summary
normalize.py's newest-first re-sort is described in-code as a "self-heal guardrail", but it fails open silently: a single malformed entry anywhere in a container disables the re-sort for that entire container, with no log line, no warning, and no entry in thechangeslist. The protection reports success while doing nothing.Found from an external AIPass project while fact-checking a claim about session counts. The corrupted file was a real agent's live
.trinity/local.json— the guardrail had been off for days and nothing surfaced it.The code
src/aipass/memory/apps/handlers/schema/normalize.py(verified onorigin/main@e26255d6, v2.7.13):Two distinct defects:
1. Silent fail-open (L148–149).
has_numbersisall(...). One entry missing thenumberkey sets itFalse, the re-sort is skipped for the whole container, and nothing is recorded —changesstays empty so the normalize run reports clean. There is nologger.warningon this path.2. Unguarded sort key (L150).
has_numbersonly checks that the key exists, not that its value is anint. If every entry has the key but one value is a string (e.g."171"instead of171),sorted()raisesTypeError: '>' not supported between instances of 'str' and 'int'inside the normalize path.Why this is worse than either bug alone
The two defects mask each other, which is how the real-world case went undetected:
numberas a string and one entry with nonumberkey at all.has_numbersFalse→ silent skip → the string never reachedsorted().has_numberstoTrue, and the run then crashes on the string.So the obvious partial fix converts a silent no-op into a hard
TypeError. Repair of an affected file has to be atomic across both defect types. This was verified empirically before filing.Impact
sessions,key_learnings,todos,observations.changes, nothing in the log, no non-zero exit.Related: the newer guard does not heal existing files
The
edit_gatenewest-first check (DPLAN-0278) landed 2026-08-01 and guards new writes. It performs no retroactive repair, so any file corrupted before that date remains silently unprotected. In the case that prompted this report, the corruption was written one day before the guard shipped.Suggested direction
Not prescribing the fix — flagging what would have surfaced it:
has_numbersisFalse, log at WARNING (or record achangesentry) naming the container and the offending index. A guardrail that declines to run should say so.isinstance(e.get("number"), int)rather than"number" in e, and handle the mixed case explicitly instead of lettingsorted()raise.Environment
origin/main@e26255d6, version 2.7.13