# 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:
| 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`