Skip to content

fix: rotate full backups and respect HOME for backup paths - #330

Merged
edheltzel merged 7 commits into
mainfrom
fix/backup-rotation
Oct 17, 2026
Merged

edheltzel merged 7 commits into
mainfrom
fix/backup-rotation

Conversation

@Atlas-Key

@Atlas-Key Atlas-Key commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

What Changed

  • Rotate install/update backups, keeping the newest two full snapshots plus latest while preserving other backup artifacts.
  • Resolve CLI backup paths from the current HOME, falling back to the operating system’s home directory.
  • Add rotation, restore-preservation, and backup-path regression coverage; consolidate backup documentation.

Risk Assessment

✅ Low: The backup-retention and HOME-resolution changes are bounded, and no material defect or unnecessary component was substantiated across the full commit range.

Testing

After installing local dependencies, focused regressions and all 12 isolated live installer/CLI scenarios passed. CLI transcripts, SQL restore results, and filesystem/database readbacks were saved. Optional model-network probes were blocked, and all disposable worktree artifacts were removed.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Repeated installations retain the newest two full snapshots with readable database records ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/01-live.txt
Config-only and empty-database installations preserve full recovery points and non-backup files ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/02-live.txt
Installation after a clock rollback protects latest alongside the two newest snapshots ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/03-live.txt
Installation handles spaces, newlines, and trailing slashes without deleting sibling directories ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/04-live.txt
Installation through a symlinked backup root creates a snapshot without pruning history ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/05-live.txt
Sorting failure produces a warning while installation succeeds and preserves snapshots ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/06-live.txt
Interactive timestamp, latest, and undo restores recover settings while preserving existing history byte for byte ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/07-live.txt
Export follows HOME changed after startup and creates distinct SQL backups that restore the original record ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/08-live.txt
Doctor saves original hooks, foreign-link contents, and stale MCP configuration under current HOME before repairing them ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/09-live.txt
Migration dry-run changes nothing; execution snapshots originals under current HOME and preserves database bytes and records ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/10-live.txt
Export with HOME unset or empty falls back to the cached OS home and produces restorable SQL ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/11-live.txt
Installation rotates legacy memory.db and canonical recall.db snapshots together, retaining readable recovery points ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/12-live.txt
Evidence: Live CLI transcripts and filesystem/database evidence
# Backup rotation live validation

All 12 scenarios were driven through the real source CLI or packaged lifecycle installer in disposable homes inside the supplied worktree. Each transcript includes the command output and observed file/database state.

Optional git manifest metadata was disabled, model-network probes were blocked, and external host CLIs were absent from the isolated PATH. No external model service or operator data was used.

| Scenario | Result | Evidence |
| --- | --- | --- |
| Repeated installation keeps the newest two full snapshots with readable database records | pass | [01-live.txt](01-live.txt) |
| Config-only and empty-database installations retain database recovery points and all non-backup files | pass | [02-live.txt](02-live.txt) |
| Installing after a clock rollback protects latest alongside the two newest snapshots | pass | [03-live.txt](03-live.txt) |
| Installation handles spaces, newlines and trailing slashes without deleting sibling directories | pass | [04-live.txt](04-live.txt) |
| Installation through a symlinked backup root creates a snapshot without pruning history | pass | [05-live.txt](05-live.txt) |
| An unavailable sorting capability warns while installation succeeds and preserves snapshots | pass | [06-live.txt](06-live.txt) |
| Interactive timestamp, latest and undo restores recover settings without rotating existing history | pass | [07-live.txt](07-live.txt) |
| Export follows HOME changed after startup and creates distinct SQL backups that restore the original record | pass | [08-live.txt](08-live.txt) |
| Doctor repairs modified hooks, foreign links and stale MCP configuration after saving originals under current HOME | pass | [09-live.txt](09-live.txt) |
| Migration dry-run changes nothing; execution snapshots originals under current HOME and preserves the database | pass | [10-live.txt](10-live.txt) |
| Export falls back to the cached OS home when HOME is unset or empty and produces restorable SQL | pass | [11-live.txt](11-live.txt) |
| Installation rotates legacy memory.db and canonical recall.db snapshots together and retains readable recovery points | pass | [12-live.txt](12-live.txt) |

Focused existing regressions: `tests/install/backup-rotation.test.ts`, `tests/commands/doctor-mcp-env.test.ts`, migration selector `CLI migrate writes pre-migrate under HOME`, and export selector `^--backup`. All passed. Their output is in [targeted-regressions.log](targeted-regressions.log). These supplement the live scenarios.

Reproduction: run `bun install --frozen-lockfile` in the supplied worktree, then `python3 ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/live-driver.py` with no leftover `.test-backup-validation/live` directory. The driver creates only disposable data in `.test-backup-validation/` and evidence in this directory.

No UI changed. CLI transcripts and persisted-state readbacks are the product evidence.

Cleanup complete: removed the disposable homes, test data, temporary wrappers, and dependencies installed by this phase. No source or test files changed.
Evidence: Reproducible isolated validation driver
import os, sys, json, sqlite3, shutil, subprocess, pathlib, hashlib, time, pty, fcntl, termios, struct, select, traceback, shlex
P = pathlib.Path('~/.no-mistakes/worktrees/fd32172f1538/01M54TTEHB4ASYXYVWXY6YW7Q6')
E = pathlib.Path('~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6')
W = P / '.test-backup-validation'
BUN = shutil.which('bun'); NODE = shutil.which('node'); NPM = shutil.which('npm')
assert BUN and NODE and NPM
W.mkdir(exist_ok=True); (W/'bin').mkdir(exist_ok=True); (W/'tmp').mkdir(exist_ok=True)
for n, p in [('bun',BUN),('node',NODE),('npm',NPM)]:
    t=W/'bin'/n
    if not t.exists(): t.symlink_to(p)
def write(p,s):
    p.parent.mkdir(parents=True,exist_ok=True); p.write_text(s)
def executable(p,s): write(p,s); p.chmod(0o755)
executable(W/'bin'/'git','#!/bin/sh\n# Optional backup manifest metadata is unavailable in this isolated environment.\nexit 1\n')
write(W/'isolate.ts', '''import { homedir } from 'node:os';
const mode = process.env.VALIDATION_HOME_MODE;
if (mode) {
  console.log('Cached OS home: ' + homedir());
  if (mode === 'change') process.env.HOME = process.env.VALIDATION_NEXT_HOME;
  if (mode === 'unset') delete process.env.HOME;
  if (mode === 'empty') process.env.HOME = '';
  console.log('Current HOME: ' + (process.env.HOME ?? '<unset>'));
}
globalThis.fetch = async () => { throw new Error('Network disabled for disposable backup validation'); };
''')
for name,entry in [('recall','index.ts'),('recall-mcp','mcp-server.ts')]:
    executable(W/'bin'/name, '#!/bin/sh\nexec '+shlex.quote(BUN)+' --preload '+shlex.quote(str(W/'isolate.ts'))+' '+shlex.quote(str(P/'src'/entry))+' "$@"\n')
BASE_ENV = {'PATH':str(W/'bin')+':/usr/bin:/bin:/usr/sbin:/sbin','TMPDIR':str(W/'tmp'),'NO_COLOR':'1','TERM':'xterm-256color','RECALL_NO_GUM':'1','HAS_GUM':'false','RECALL_PACKAGED':'1','NO_CONFIRM':'true','OLLAMA_URL':'http://127.0.0.1:1','LANG':'en_US.UTF-8'}
results=[]; out=None
if os.environ.get('VALIDATION_CASES') and (E/'live-results.json').exists(): results=json.loads((E/'live-results.json').read_text())

def emit(s):
    out.write(s+'\n'); out.flush()
def state(v): emit('OBSERVED FILESYSTEM / DATABASE STATE\n'+json.dumps(v,indent=2))
def env(home, **extra):
    home.mkdir(parents=True,exist_ok=True)
    e={**BASE_ENV,'HOME':str(home),'CLAUDE_DIR':str(home/'.claude'),'RECALL_HOME':str(home/'.agents/Recall'),'RECALL_DB_PATH':str(home/'.agents/Recall/recall.db'),'BUN_INSTALL':str(home/'.bun'),'XDG_CONFIG_HOME':str(home/'.config'),'PI_CODING_AGENT_DIR':str(home/'.pi/agent'),'GROK_HOME':str(home/'.grok')}
    e.update({k:str(v) for k,v in extra.items()}); return e

def run(args,e,ok=0,interactive=False):
    emit('$ '+shlex.join(list(map(str,args))))
    if not interactive:
        r=subprocess.run(args,cwd=P,env=e,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=80)
        emit(r.stdout); emit('exit='+str(r.returncode)); assert r.returncode==ok,r.stdout; return r.stdout
    master,slave=pty.openpty(); fcntl.ioctl(slave,termios.TIOCSWINSZ,struct.pack('HHHH',40,160,0,0))
    p=subprocess.Popen(args,cwd=P,env=e,stdin=slave,stdout=slave,stderr=slave,start_new_session=True); os.close(slave)
    data=b''; sent=False; deadline=time.monotonic()+40
    try:
        while time.monotonic()<deadline:
            if select.select([master],[],[],0.1)[0]:
                try: b=os.read(master,65536)
                except OSError: break
                if not b: break
                data+=b
                if b'Proceed with restore?' in data and not sent:
                    os.write(master,b'y\n'); sent=True
            if p.poll() is not None:
                while select.select([master],[],[],0)[0]:
                    try: data+=os.read(master,65536)
                    except OSError: break
                break
        if p.poll() is None: p.kill()
        rc=p.wait(timeout=5); s=data.decode(errors='replace'); emit(s);emit('exit='+str(rc)); assert sent and rc==ok,s; return s
    finally: os.close(master)

def cli(home,*args,**extra): return run([W/'bin/recall',*args],env(home,**extra))
def install(home,stamp,**extra):
    e=env(home,TIMESTAMP=stamp,**extra)
    return run(['/bin/bash',P/'packaging/install.sh','--yes','--no-gum','--skip-age-cron'],e)
def dbpath(h): return h/'.agents/Recall/recall.db'
def backups(h): return h/'.agents/Recall/backups'
def newhome(n): return W/'live'/n

def seed(h,text='backup validation marker'):
    cli(h,'init');cli(h,'add','breadcrumb',text,'--project','backup-validation')
    return dbpath(h)
def snapshot(base,name,db=None,settings=None,db_name='recall.db'):
    d=base/name; d.mkdir(parents=True,exist_ok=True)
    write(d/'manifest.txt','Recall Backup Manifest\nTimestamp: '+name+'\n')
    if db: shutil.copy2(db,d/db_name)
    if settings is not None: write(d/'settings.json',json.dumps(settings))
    return d

def rows(p):
    c=sqlite3.connect('file:'+str(p)+'?mode=ro',uri=True)
    try: return [r[0] for r in c.execute('select content from breadcrumbs order by id')]
    finally:c.close()
def digest(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def names(p):return sorted(x.name for x in p.iterdir())
def runscenario(i,name,f):
    global out
    file=E/f'{i:02d}-live.txt'
    with file.open('w') as out:
        emit(name);emit('Real product: source CLI and packaged lifecycle installer. HOME, DB, host configuration, tool bins and temp files are isolated inside the worktree. Optional model-network probes are blocked; optional git manifest metadata is unavailable.\n')
        try:f(); result='pass';reason=''
        except Exception as ex:
            emit(traceback.format_exc());result='fail';reason=str(ex)
        emit('SCENARIO RESULT: '+result)
    results.append({'name':name,'result':result,'live':True,'evidence':str(file),'reason':reason})
    (E/'live-results.json').write_text(json.dumps(results,indent=2))
    print(f'{i:02d} {result}: {name}',flush=True)

def retention():
    h=newhome('retention');seed(h,'snapshot 1');write(h/'.claude/settings.json','{}')
    for i in range(1,5):
        if i>1:cli(h,'add','breadcrumb',f'snapshot {i}','--project','backup-validation')
        install(h,f'20260101_00000{i}')
    b=backups(h); assert names(b)==['20260101_000003','20260101_000004','latest']
    payload={s:rows(b/s/'recall.db') for s in names(b) if s!='latest'}
    assert payload['20260101_000003']==['snapshot 1','snapshot 2','snapshot 3']
    assert payload['20260101_000004']==['snapshot 1','snapshot 2','snapshot 3','snapshot 4']
    state({'retained':names(b),'latest':(b/'latest').read_text(),'database_rows':payload})

def partials():
    h=newhome('partials');db=seed(h);b=backups(h)
    for i in range(1,4):snapshot(b,f'20260101_00000{i}',db)
    protected={}
    for name in ['pre_restore_20250101_000000','.hidden','repair-only','pre-migrate-only','no-manifest','empty-db','config-only']:
        d=b/name;d.mkdir(exist_ok=True)
        write(d/'sentinel','preserve '+name);protected[name]=(d/'sentinel').read_text()
        if name not in ['no-manifest','pre_restore_20250101_000000'] :write(d/'manifest.txt','partial')
        if name in ['.hidden','pre_restore_20250101_000000','no-manifest']:shutil.copy2(db,d/'recall.db')
        if name=='empty-db':write(d/'recall.db','')
        if name=='pre-migrate-only':write(d/'pre-migrate/source.db','migration bytes')
    write(b/'export.sql','keep loose export');outside=h/'outside';snapshot(outside,'real',db);(b/'linked').symlink_to(outside/'real',target_is_directory=True)
    for i in range(1,4):
        db.unlink(missing_ok=True)
        if i==3:write(db,'')
        install(h,f'20261014_00000{i}')
    for n,s in protected.items():assert (b/n/'sentinel').read_text()==s
    assert (b/'linked').is_symlink() and (outside/'real/recall.db').exists()
    assert (b/'export.sql').read_text()=='keep loose export'
    assert not (b/'20260101_000001').exists()
    assert rows(b/'20260101_000002/recall.db')==rows(b/'20260101_000003/recall.db')==['backup validation marker']
    state({'retained':names(b),'protected_sentinels':protected,'old_full_database_rows':rows(b/'20260101_000002/recall.db'),'directory_symlink':os.readlink(b/'linke

... [1953 bytes truncated] ...

 "$1" = "-zr" ]; then echo "Injected unavailable NUL-sort capability" >&2; exit 2; fi\nexec /usr/bin/sort "$@"\n')
    s=install(h,'20261017_000001',PATH=str(fault)+':'+BASE_ENV['PATH'])
    assert 'Backup rotation failed' in s and 'Recall installed successfully' in s
    assert names(b)==[f'20260101_00000{i}' for i in range(1,4)]+['20261017_000001','latest']
    state({'retained_after_sort_failure':names(b),'new_snapshot_rows':rows(b/'20261017_000001/recall.db'),'warning_visible':True,'installer_exit':0})

def restores():
    h=newhome('restores');db=seed(h);b=backups(h)
    for i in range(1,5):snapshot(b,f'20260101_00000{i}',db,{'snapshot':i})
    write(b/'latest','20260101_000004\n');original={str(p.relative_to(b)):digest(p) for p in b.rglob('*') if p.is_file()}
    checks=[]
    for i,(args,expected) in enumerate([(['20260101_000001'],{'snapshot':1}),([],{'snapshot':4}),(['pre_restore_20261017_080001'],{'current':1})],1):
        current={'current':i};write(h/'.claude/settings.json',json.dumps(current));before=names(b)
        e=env(h,TIMESTAMP=f'20261017_08000{i}',NO_CONFIRM='false')
        run(['/bin/bash',P/'packaging/install.sh','restore',*args],e,interactive=True)
        assert json.loads((h/'.claude/settings.json').read_text())==expected
        pre=f'pre_restore_20261017_08000{i}';assert json.loads((b/pre/'settings.json').read_text())==current
        assert names(b)==sorted(before+[pre])
        assert all(digest(b/p)==sha for p,sha in original.items())
        checks.append({'request':args or ['latest'],'restored_settings':expected,'saved_undo_settings':current,'existing_history_byte_unchanged':True})
    state({'restores':checks,'retained':names(b),'latest_unchanged':(b/'latest').read_text()})

def restore_sql(p,expected):
    target=p.with_suffix('.restored.db');c=sqlite3.connect(target)
    try:c.executescript(p.read_text());got=[r[0] for r in c.execute('select content from breadcrumbs order by id')];assert got==expected;return got
    finally:c.close()
def exports():
    h=newhome('export-cached-home');next_h=newhome('export-current-home');seed(h,'portable backup record')
    for _ in range(2):cli(h,'export','--backup',VALIDATION_HOME_MODE='change',VALIDATION_NEXT_HOME=next_h)
    files=sorted(backups(next_h).glob('*.sql')); assert len(files)==2 and not backups(h).exists()
    states=[{'file':str(p.relative_to(W)),'bytes':p.stat().st_size,'restored_rows':restore_sql(p,['portable backup record'])} for p in files]
    state({'cached_home':str(h),'current_HOME':str(next_h),'exports':states,'cached_home_received_no_backups':not backups(h).exists()})

def doctor():
    h=newhome('doctor-cached-home');new_h=newhome('doctor-current-home');seed(h)
    root=h/'.agents/Recall';hooks=root/'shared/hooks';target=h/'.claude/hooks'
    for n in ['RecallStart.ts','RecallExtract.ts']:write(hooks/n,(P/'hooks'/n).read_text())
    write(target/'RecallStart.ts','operator modified hook bytes\n');foreign=h/'foreign-hook.ts';write(foreign,'foreign symlink bytes\n');(target/'RecallExtract.ts').symlink_to(foreign)
    config=h/'.claude.json';original=json.dumps({'mcpServers':{'recall-memory':{'command':'recall-mcp','env':{'RECALL_DB_PATH':str(h/'old.db')}}}});write(config,original)
    cli(h,'doctor','--fix',VALIDATION_HOME_MODE='change',VALIDATION_NEXT_HOME=new_h)
    for n in ['RecallStart.ts','RecallExtract.ts']:assert (target/n).is_symlink() and os.readlink(target/n)==str(hooks/n)
    assert json.loads(config.read_text())['mcpServers']['recall-memory']['env']['RECALL_DB_PATH']==str(dbpath(h))
    saved={p.name:p.read_text() for p in backups(new_h).glob('*/doctor-fix/*')}
    assert 'operator modified hook bytes\n' in saved.values() and 'foreign symlink bytes\n' in saved.values() and original in saved.values()
    assert not backups(h).exists() and foreign.read_text()=='foreign symlink bytes\n'
    state({'backup_HOME':str(new_h),'saved_originals':saved,'repaired_symlinks':{n:os.readlink(target/n) for n in ['RecallStart.ts','RecallExtract.ts']},'patched_database_path':json.loads(config.read_text())['mcpServers']['recall-memory']['env']['RECALL_DB_PATH'],'foreign_file_unchanged':True})

def migration():
    h=newhome('migration-cached-home');new_h=newhome('migration-current-home');source=seed(h,'migration retained record');dest=new_h/'custom/recall.db';cfg=new_h/'.claude.json'
    original=json.dumps({'mcpServers':{'recall-memory':{'command':'recall-mcp','env':{'RECALL_DB_PATH':str(source)}}}});write(cfg,original)
    oldsha=digest(source);opts={'VALIDATION_HOME_MODE':'change','VALIDATION_NEXT_HOME':new_h}
    cli(h,'migrate','--to',str(dest),'--dry-run',**opts)
    assert digest(source)==oldsha and not dest.exists() and cfg.read_text()==original and not backups(new_h).exists()
    cli(h,'migrate','--to',str(dest),**opts)
    files=list(backups(new_h).glob('*/pre-migrate/source.db'));assert len(files)==1 and digest(files[0])==oldsha
    assert not source.exists() and digest(dest)==oldsha and rows(dest)==['migration retained record']
    assert json.loads(cfg.read_text())['mcpServers']['recall-memory']['env']['RECALL_DB_PATH']==str(dest)
    assert (files[0].parent/'.claude.json').read_text()==original and not backups(h).exists()
    state({'dry_run_changed_nothing':True,'pre_migrate_snapshot':str(files[0].relative_to(W)),'original_and_snapshot_sha256':oldsha,'destination_sha256':digest(dest),'destination_rows':rows(dest),'configuration_patched':json.loads(cfg.read_text()),'original_config_preserved':True})

def fallback():
    checks=[]
    for mode in ['unset','empty']:
        h=newhome('fallback-'+mode);seed(h,'fallback '+mode)
        cli(h,'export','--backup',VALIDATION_HOME_MODE=mode)
        files=list(backups(h).glob('*.sql'));assert len(files)==1
        checks.append({'HOME_mode':mode,'cached_os_home':str(h),'output':str(files[0].relative_to(W)),'restored_rows':restore_sql(files[0],['fallback '+mode])})
    state(checks)

def legacy():
    h=newhome('legacy');db=seed(h,'legacy and canonical recovery record');b=backups(h)
    for i in range(1,4):snapshot(b,f'20260101_00000{i}',db,db_name='memory.db')
    install(h,'20260101_000004')
    assert names(b)==['20260101_000003','20260101_000004','latest']
    old=rows(b/'20260101_000003/memory.db');new=rows(b/'20260101_000004/recall.db')
    assert old==new==['legacy and canonical recovery record']
    state({'retained':names(b),'legacy_memory_db_rows':old,'canonical_recall_db_rows':new})

cases=[
('Repeated installation keeps the newest two full snapshots with readable database records',retention),
('Config-only and empty-database installations retain database recovery points and all non-backup files',partials),
('Installing after a clock rollback protects latest alongside the two newest snapshots',clockback),
('Installation handles spaces, newlines and trailing slashes without deleting sibling directories',weirdnames),
('Installation through a symlinked backup root creates a snapshot without pruning history',symlinkroot),
('An unavailable sorting capability warns while installation succeeds and preserves snapshots',sortfailure),
('Interactive timestamp, latest and undo restores recover settings without rotating existing history',restores),
('Export follows HOME changed after startup and creates distinct SQL backups that restore the original record',exports),
('Doctor repairs modified hooks, foreign links and stale MCP configuration after saving originals under current HOME',doctor),
('Migration dry-run changes nothing; execution snapshots originals under current HOME and preserves the database',migration),
('Export falls back to the cached OS home when HOME is unset or empty and produces restorable SQL',fallback),
('Installation rotates legacy memory.db and canonical recall.db snapshots together and retains readable recovery points',legacy),
]
for i,(n,f) in enumerate(cases,1):
    if not os.environ.get('VALIDATION_CASES') or str(i) in os.environ['VALIDATION_CASES'].split(','):runscenario(i,n,f)
print(json.dumps({'pass':sum(r['result']=='pass' for r in results),'fail':sum(r['result']=='fail' for r in results)}),flush=True)
sys.exit(1 if any(r['result']=='fail' for r in results) else 0)

Pipeline

Updates from git push no-mistakes

⏭️ **intent** - skipped

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Repeated installations retain the newest two full snapshots with readable database records ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/01-live.txt
Config-only and empty-database installations preserve full recovery points and non-backup files ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/02-live.txt
Installation after a clock rollback protects latest alongside the two newest snapshots ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/03-live.txt
Installation handles spaces, newlines, and trailing slashes without deleting sibling directories ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/04-live.txt
Installation through a symlinked backup root creates a snapshot without pruning history ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/05-live.txt
Sorting failure produces a warning while installation succeeds and preserves snapshots ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/06-live.txt
Interactive timestamp, latest, and undo restores recover settings while preserving existing history byte for byte ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/07-live.txt
Export follows HOME changed after startup and creates distinct SQL backups that restore the original record ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/08-live.txt
Doctor saves original hooks, foreign-link contents, and stale MCP configuration under current HOME before repairing them ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/09-live.txt
Migration dry-run changes nothing; execution snapshots originals under current HOME and preserves database bytes and records ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/10-live.txt
Export with HOME unset or empty falls back to the cached OS home and produces restorable SQL ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/11-live.txt
Installation rotates legacy memory.db and canonical recall.db snapshots together, retaining readable recovery points ✅ pass live ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/12-live.txt
  • bun install --frozen-lockfile to materialize temporary local dependencies.
  • bun test tests/install/backup-rotation.test.ts tests/commands/doctor-mcp-env.test.ts with isolated HOME, TMPDIR, RECALL_HOME, and RECALL_DB_PATH.
  • bun test tests/commands/migrate.test.ts -t &#39;CLI migrate writes pre-migrate under HOME&#39; with isolated paths.
  • bun test tests/commands/export.test.ts -t &#39;^--backup&#39; with isolated paths.
  • python3 ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/live-driver.py drove scenarios 1–11 through the real installer and source CLI.
  • VALIDATION_CASES=12 python3 ~/.no-mistakes/evidence/01M54TTEHB4ASYXYVWXY6YW7Q6/live-driver.py exercised mixed legacy/canonical rotation.
  • Reimported generated SQL, queried retained SQLite records, compared migration and restore hashes, and drove interactive restores through a drained 40×160 PTY.
  • Removed disposable homes, data, wrappers, and dependencies created by this phase; preserved evidence.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@Atlas-Key
Atlas-Key requested a review from edheltzel as a code owner October 14, 2026 19:23
@edheltzel
edheltzel force-pushed the fix/backup-rotation branch 2 times, most recently from 44afa9e to 56b6ab8 Compare October 16, 2026 17:17
@edheltzel
edheltzel force-pushed the fix/backup-rotation branch from 56b6ab8 to 595a4db Compare October 17, 2026 11:24
@edheltzel
edheltzel merged commit cc6770c into main Oct 17, 2026
3 checks passed
@edheltzel
edheltzel deleted the fix/backup-rotation branch October 17, 2026 15:16
edheltzel pushed a commit that referenced this pull request Oct 17, 2026
* fix(backup): keep the newest 2 full backups and follow HOME

* no-mistakes(review): Preserve backup history during restores

* no-mistakes(document): Clarify backup retention and consolidate restore documentation

* fix(backup): rotate only backups that copied a database

recall_create_backup writes manifest.txt even when it found no database to
copy, so two DB-less installs or updates could rotate away the older
recall.db snapshots. Count a backup only when it holds a non-empty recall.db
or memory.db.

* fix(backup): rotate backup names that contain spaces or newlines

* no-mistakes(document): Clarify backup retention safeguards and HOME resolution

* fix(backup): keep a spaced latest name when rotating

---------

Co-authored-by: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants