Skip to content

memory/normalize: newest-first re-sort guardrail fails open silently on a single malformed entry (and crashes if half-repaired) #728

Description

@AIOSAI

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 recordedchanges 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:

  1. 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.
  2. 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.
  3. Consider coercing or quarantining rather than skipping wholesale — a single bad row currently forfeits protection for every good row beside it.
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions