# Phase 4 Round 17 Plan — Stale Detection

## Summary

Round 16 added confidence scoring. Round 17 adds **staleness
detection**: memories that haven't been retrieved in a long time get
a negative score contribution so they naturally sink below fresher,
more relevant memories.

The key design constraint: **no ledger schema change**. The ledger
stays append-only and unchanged. Instead, a lightweight side-channel
file (`reference-tracker.json`) records when each record was last
retrieved. This file is:

- independent of the ledger (can be deleted without data loss)
- not part of the projection (projection stays rebuildable from ledger)
- written as a fire-and-forget side-effect of retrieval

If the tracker file is missing or corrupt, staleness scoring
gracefully degrades to zero (no penalty, no boost).

## Reference Tracker

New module: `src/reference_tracker.rs`

File location: `<lifecycle_root>/reference-tracker.json`

Format:
```json
{
  "schema_version": "reference-tracker.v1",
  "records": {
    "<record_id>": { "last_referenced_at": "2026-05-08T12:00:00Z", "count": 5 },
    ...
  }
}
```

API:
- `touch(root: &Path, record_ids: &[&str])` — update timestamps + increment count
- `read(root: &Path) -> ReferenceMap` — load the tracker
- `age_days(entry: &ReferenceEntry) -> Option<u64>` — days since last reference

The file uses `fcntl` advisory locking for concurrent access (same
pattern as the distill queue).

## Staleness Scoring

`ScoreSource` gets a new variant: `Staleness`.

### Lifecycle candidates (`score_lifecycle_candidate`)

The scorer receives an optional `&ReferenceMap` parameter. For each
candidate, it looks up the record's last reference time and applies:

| Days since last reference | Weight |
|---|---:|
| 0–14 (fresh) | 0 |
| 15–30 | -2 |
| 31–60 | -4 |
| 61–90 | -6 |
| 91+ | -8 |
| Never referenced (no tracker entry) | 0 |

"Never referenced" gets zero penalty because a brand-new memory
shouldn't be penalized for not having been retrieved yet.

### Note candidates (`score_note`)

Vault notes don't have record IDs in the tracker, so staleness
scoring only applies to lifecycle candidates in Round 17. A future
round could track note paths, but that's out of scope.

## Retrieval Touch Path

After `memory_gateway::execute` returns a successful response, the
gateway touches all lifecycle candidate record IDs that made it into
the final bundle. This is a fire-and-forget write — failure is
logged to stderr and swallowed.

Touch happens in:
- `memory_gateway::execute` (Context intent)
- `memory_gateway::build_wakeup_from_config` (Wakeup intent)

## DTO Changes

- `domain::note::ScoreSource::Staleness` (new variant)
- No new fields on `LifecycleCandidate` — staleness is visible
  through the existing `score_breakdown` surface

Frontend bindings regenerated via `cargo test --lib export_bindings`.

## Test Plan

1. **Unit (reference_tracker)**:
   - `touch` creates file if absent
   - `touch` updates existing entries
   - `read` returns empty map for missing file
   - `age_days` computes correctly

2. **Unit (scorer)**:
   - lifecycle candidate with 60-day-old reference gets -4 penalty
   - lifecycle candidate with no tracker entry gets 0 penalty
   - lifecycle candidate with fresh (< 14 days) reference gets 0
   - sum invariant still holds (breakdown weights sum to score)

3. **Integration (memory_gateway)**:
   - after retrieval, tracker file contains touched record IDs
   - tracker file is created on first retrieval

4. **CLI smoke**:
   - existing tests still pass (staleness is additive, doesn't break
     existing scoring)

5. **Bench**:
   - `build_bundle@5000` stays under 200ms red line

## Out of Scope

- ❌ Note-level staleness (no record IDs for vault notes)
- ❌ Auto-archival of stale memories (needs its own ADR)
- ❌ UI for staleness visibility (frontend can read breakdown)
- ❌ Configurable decay curve (hardcoded thresholds for now)

## Completion Status

Last checked: `2026-05-08`
