# Task freshness

Confirmed tasks carry `[fresh:: YYYY-MM-DD]`: the local calendar date a human last confirmed that the task still needs doing as written — its wording, priority, project, schedule, and dependencies. A visible, non-recurring Ready task that was never confirmed, whose scheduled deferral returned after its last confirmation, which was automatically unblocked after its stamp, or whose review interval expired is due for review. Today-linked tasks and canonical daily-note tasks are outside this review scope. The morning review then costs roughly "pool ÷ interval + arrivals" glances instead of the whole pool. Recurring tasks are never stamped; once a recurring occurrence's date arrives it walks in the RECURRING tier until it is completed, rescheduled, linked to Today, or cancelled.

The daily lane review reuses the same `[fresh::]` stamp for Pending (`[/]`) and Next (`[*]`) tasks: each lane has a review cadence (`freshness.pending_interval` / `freshness.next_interval`, default 1 day), and the morning walk visits PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST in explicit tiers.

This file is the contract both implementations cite. The Rust side is `src/native/freshness/` (`placement.rs`, `state.rs`) with the `freshness:` config block in `src/native/config/freshness.rs`; the JavaScript mirror is `api.freshness` in bob-ledger-tools (top-level api v3, freshness namespace v12 with the `recurringTier` capability and `markUnblockedLine`). The bob-ledger-tools JavaScript tests use the conformance vectors below verbatim.

The keep-streak contract (`keeps`, `decay`, introduced in schema 4) is specified here and implemented in Rust in the contract-rust phase. Its machine-readable parity vectors live in `tests/fixtures/freshness_keeps/vectors.json`, which both languages cite: Rust runs the read/reset/placement cases (it has no production increment API); the sole increment helper is JavaScript `api.freshness.keepLine`, which landed with the ledger-marks phase alongside freshness namespace v5 counting, folded pips, and count-truthful tooltips. The decision card landed with the decision-card phase (bob-navigation-hotkeys 1.69.0, bob-ledger-tools 1.24.0) and is available immediately on compatible plugins (freshness namespace v6, card capability v2): single Alt+F/Ctrl+Alt+F presses on exact, due, at-limit tasks open the consent card and write nothing, counted and Task Link sessions skip those targets without changing fresh/count, and marks show the leaf with `Alt+F to decide` only when the card capability is present and decay is on. Mixed-version sessions keep counting pips with counting-only wording and no leaf.

## 1\. Definition

- **Task freshness** (aka **freshness**) is the local calendar date a human last confirmed that an open task still needs doing as written. It is stored as `[fresh:: YYYY-MM-DD]`.
- **Stamp / refresh:** write today's date into `fresh`.
- **Due for review:** an in-scope task in state NEW, RESURFACED, or ROTTEN (see §4).
- **Refreshed today:** a task whose `fresh` equals today.

A missing stamp — not `created` — means new. `bob gkeep pull` sets `created` from the Keep note's creation time (`src/native/gkeep/render.rs`), so `created` records when the thought was first captured elsewhere, not when Bryan first saw it in the vault. Every new capture therefore arrives without `fresh` and is due as NEW until a human confirms it.

## 2\. Fields, overrides, and config

| What | Where | Value |
| --- | --- | --- |
| `[fresh:: YYYY-MM-DD]` | task line, before the trailing Tasks suffix | optional; absence means "never confirmed" |
| `[refresh:: N]` | task line, immediately after `fresh` | optional integer days, 1–365 |
| `[keeps:: N]` | task line, immediately after `refresh` | optional streak of due-Ready keeps, 1–999; absence means 0 (§2a) |
| `[unblocked:: YYYY-MM-DD]` | task line, immediately after `keeps` | optional recorded date of an automatic Blocked → Ready recovery; absence means none (§4) |
| `task_refresh: N` | frontmatter of the note containing the task | optional integer days, 1–365 |
| `freshness.interval` | `~/.config/bob/config.yml` | integer days, 1–365, default 7 |
| `freshness.pending_interval` | `~/.config/bob/config.yml` | integer days, 1–365, or `false`; default 1 |
| `freshness.next_interval` | `~/.config/bob/config.yml` | integer days, 1–365, or `false`; default 1 |
| `freshness.project_interval` | `~/.config/bob/config.yml` | optional integer days, 1–365; absent/null inherits |
| `freshness.rotten_daily_budget` | `~/.config/bob/config.yml` | optional integer ≥ 1, default off |
| `freshness.decay` | `~/.config/bob/config.yml` | keep-streak policy: mapping, `true`, `false`, or null (§2a) |

**Interval precedence.** A configured project\_interval overrides every other level for ^prj rows (source project). References have no special cadence or grouping: Ready references use the ordinary chain refresh → task\_refresh → freshness.interval → 7; Pending and Next references use their lane interval, like other tasks. Otherwise interval(t) for an ordinary task in a walked lane is that lane's interval (pending\_interval for \[/\] with source pending, next\_interval for \[\*\] with source next), overriding the Ready chain below. The note override applies by residence, not through parent links. pending\_interval: false / next\_interval: false turns that lane's walk off for ordinary tasks, including references, but never ^prj review — PROJECTS walks even with the lane off; an absent or null lane value means the default 1. An absent/null project key inherits the Ready chain.

**Invalid values.**

- An invalid task or note value is linted and falls through to the next level.
- An invalid `freshness:` block is a config error. `bob freshness` exits 2; ledger-tools falls back to the defaults and marks them `invalid`.
- Unknown keys are ignored, and a mistyped `freshness:` must not break any other config loader (it is read as a raw `serde_yaml::Value`, mirroring how `plan:` is loaded).

Example:

```
freshness:
  interval: 7 # Ready backlog review cadence
  pending_interval: 1 # [/] lane daily review; false = not walked
  next_interval: 1 # [*] lane daily review; false = not walked
  # project_interval: 1 # ^prj review cadence; absent/null inherits

  # rotten_daily_budget: 15 # counts upkeep outside the lanes; never hides tasks
```

Booleans (including `false`), zero, negatives, values above 365, fractional numbers, strings, and containers are config errors for the tracker keys; `bob freshness` exits 2 while ledger-tools uses the complete default config and flags it invalid. Other config loaders still ignore a malformed freshness block.

**Mobile config caveat.** On mobile, when the config file is unavailable, ledger-tools uses the defaults above and marks them `invalid` rather than failing; a config edit on desktop is visible within the existing 60-second tick. `bob freshness` on desktop still exits 2 for an invalid `freshness:` block. The mobile/missing-file fallback carries neither tracker override and cannot see Bryan's desktop settings.

## 2a. Keep streaks and approved-decay policy

**Storage.** `[keeps:: N]` is a streak of due-Ready bare keeps: how many times in a row a due Ready task was confirmed without any other change. It is a decimal integer 1–999; absence means 0 and writers omit zero. Increment saturates at 999 and never wraps. Readers accept existing bracket or paren field syntax, select the first valid value, and emit `keeps_invalid` and `keeps_duplicate` as appropriate; if none is valid, they report 0. `fresh_misplaced` also covers `keeps` and `unblocked` inside the Tasks suffix. A successful keep canonicalizes and repairs malformed/duplicate placement. Generic stamps remove all `keeps` fields and all `unblocked` fields. An uncounted keep preserves the valid semantic value while canonicalizing it. Do not introduce a second lifetime counter or alias: the Rotten Log entry count below is display-only history, counted from dated entries at read time and never stored as a field.

Canonical form:

```
- [ ] #task Rename queue input [fresh:: 2026-10-08] [refresh:: 14] [keeps:: 2]
      [unblocked:: 2026-10-08] [created:: 2026-09-12] [priority:: medium] ^rq
```

Output order is `fresh`, optional `refresh`, optional `keeps`, optional `unblocked`, then the existing Tasks suffix, tags, and block ID. `keeps` and `unblocked` extend the suffix scan as run-extending **non-Tasks** keys; they are never added to the Tasks key registry. `unblocked` must never be appended after the Tasks suffix: both Rust Tasks parsers stop at the first unknown key, so they would lose `scheduled`, `dependsOn`, and `id`. Both Rust parsers see the same Tasks fields before and after writes. Task body, child blocks, quote prefixes, CRLF, and final-newline state follow the existing writer rules. No metadata leaks into clean task descriptions or capture previews.

**Counting, clearing, and storage.** Exact eligibility authorizes counting only for an exact due-Ready ROTTEN/TICKLER row: the pre-write queue holds exactly one row with `entry.path === path`, `entry.line === editorLine + 1`, `entry.originalMarkdown === rawLine`, `lane === 'ready'`, and `tier in {'rotten','tickler'}`. The sole increment helper is `api.freshness.keepLine(line, dateText, { counted })` with its valid-prior- `fresh < today` guard; Rust reads/clears/reports with no increment path (the seed preserve-mode exception aside). A v5 provider with a missing or throwing `keepLine` fails without writing, while pre-v5 falls back to the uncounted old stamper. The event table:

- Alt+F / Ctrl+Alt+F on an exact due Ready target in `rotten`/`tickler` stamps and increments once in the same write, unless an active decision is required.
- NEW, FRESH/early, Pending, Next, Blocked, Today-linked, PROJECTS rows, other excluded targets, and unresolved cache matches stamp through `keepLine` uncounted and preserve the streak.
- A repeated same-day keep preserves the streak and is byte-identical.
- Every other supported human stamp clears the streak, even same-day.
- Close/cancel preserves the streak on the closed line.
- Automation, hooks, randomize, and seed neither increment nor reset.
- Recurring and closed targets are refused.
- PROJECTS trackers never `decide` and their explicit keeps are uncounted. Due Ready references use ordinary keep counting and can reach the approved-decay decision at the keep limit. Dependency capture (`bob   capture` `&`) is a generic stamp that clears `keeps`.
- A keep on a `returned_by: unblocked` TICKLER row neither increments nor resets `[keeps::]`, and `decide_for` never opens the decision card for it (`unblock_keeps`). The row is still due and still stamps `[fresh:: today]`, which clears `[unblocked::]`.

What Rust guarantees today:

- every generic human stamp (`stamp_fresh`, `set_refresh`, including same-day stamps) clears `keeps`;
- refusals (not a task, recurring, closed) write nothing, so the streak stays on the closed line;
- automation, hooks, randomize, and the seed neither increment nor reset — `seed.rs::stamp_change` stamps through the private preserve-mode primitive, so the cutover never silently resets a streak;
- capture's existing `stamp_fresh` consumers clear `keeps` through the same default.

No backfill: seeded dates, captured dates, and old `fresh` stamps are not evidence of keeps. Git sync may lose an increment during conflict recovery; this scalar is not an exact global event counter. Cache uncertainty under-counts. Hand editing remains unobserved.

**Rotten Log.** Every counted keep — Alt+F/Ctrl+Alt+F on an exact due Ready ROTTEN or scheduled TICKLER row, or the decay card's Keep — first asks "Why keep it as written?" and then writes one dated entry under a `🥀 **ROTTEN LOG**` direct child, in the same write as the stamp. The reason is optional (a blank Enter logs `🤷 no reason given`) but the entry is not; Esc writes nothing. Each entry reads `*YYYY-MM-DD* — <reason> · kept N×` with N the streak after that keep, newest first. The entry count ("N keeps logged") is display-only history: unlike `[keeps:: N]` it never resets, and nothing reads it to change decay, buckets, tiers, lanes, Today, schedule, priority, or freshness. Grammar lives in [the vault format](<https://bobs-org.github.io/reference/vault-format/index.md>). See [Rotten Keeps Are Logged](<https://bobs-org.github.io/develop/decisions/#rotten-keeps-are-logged>).

**Configuration.**

```
freshness:
  decay:
    keeps: 3
    # enter: P2
```

`decay` absent, null, `true`, or `{}` means enabled with 3 keeps. `false` keeps counting/display but never asks or skips. `keeps` accepts an integer 0–999; 0 asks on every due Ready re-confirmation, never NEW. `enter` is an optional nonempty configured priority label; absent/null uses interval-aware entry. Invalid scalar types, fractional/negative/out-of-range values, and malformed blocks follow the current freshness config failure contract: Rust exit 2; plugin defaults with `invalid` diagnostics. Priority config validity is resolved against the existing priority loader, not a second hard-coded P1–P4 table.

**Availability.** Decay decisions are available as soon as decay is enabled and compatible plugins are loaded. There is no calendar gate, replacement date, or counting-only period. On an exact, due Ready ROTTEN/TICKLER task at the keep limit, a single Alt+F/Ctrl+Alt+F press opens the consent card and writes nothing, while counted and Task Link sessions skip those targets (`N needs a decision`) without changing fresh/count. Below threshold they keep counting normally through exact pre-write matching into `keepLine`, with `kept N×` tails on the Fresh notice. A card still requires an explicit gesture, never a timer write. `freshness.decay: false` remains the off-switch: count and show pips, without cards or decision skips. New navigation with an older ledger falls back to counted keeps; new ledger with older or missing navigation shows pips without a card promise.

**Read-time decision flag.** `decide = enabled && lane ready && tier rotten/tickler && keeps >= limit`. The annotation means a choice is due, not permission to execute an action. The shared approved-decay action planner (`planFreshnessDecayCard` in bob-navigation-hotkeys) turns that flag into stable previewed decisions — P0 entry, a non-cancelling Not now, Less often steps, and dated review-decision entries with `· kept N×` tails — as specified in `docs/projects.md` ("Approved-decay decision planner"); the card interaction itself (`FreshnessDecayCardModal` plus guarded commit adapters, batch skipping, the leaf, and the capability guard) landed with the decision-card phase.

## 3\. Placement rule

The Rust helper is `stamp_fresh` / `set_refresh`; the JavaScript ones are `api.freshness.stampLine` / `setRefreshLine` / `keepLine` / `markUnblockedLine` in bob-ledger-tools (`keepLine` is the sole increment helper and landed with the ledger-marks phase; `markUnblockedLine` is the sole `[unblocked::]` writer, namespace ≥ v12). One helper per language, pinned by the shared vectors below. Rust reads, clears, and reports `keeps` and `unblocked`; the seed's private preserve-mode primitive is the only Rust path that keeps a streak while stamping. Every stamp (Rust stamp helpers; JS `stampLine` / `keepLine`) removes `[unblocked::]`.

1. **Scope.** The helpers handle Tasks' Dataview format only, which is the vault's format. `bob freshness` refuses to run when the vault's Tasks settings use another format. Task-line detection strips leading `>` quote markers first (up to three spaces before each `>`, one optional space after), matching both Rust parsers — a quoted task is still a task.
2. **The Tasks suffix.** Scan from the end of the line, the way both parsers do:
3. first an optional trailing `^id` (the `BLOCK_LINK` grammar);
4. then repeatedly whitespace plus one of:

   - a `[k:: v]` or `(k:: v)` field whose key is a Tasks key (`priority start created scheduled due completion cancelled    repeat onCompletion id dependsOn`), whatever its value, following `trailing_inline_field`'s grammar;
   - a `fresh`, `refresh`, `keeps`, or `unblocked` field;
   - a trailing tag (the `HASH_TAG_AT_END` grammar).
5. Never scan past the start of the task body. If the body begins with the global filter token `#task`, never scan past the end of that token.

The **Tasks suffix** starts at the leftmost Tasks element of that run (a Tasks-key field, a tag, or `^id`). `fresh` / `refresh` / `keeps` / `unblocked` fields at the run's left edge are not part of it. 3. **Canonical output.** - Remove every `fresh`, `refresh`, `keeps`, and `unblocked` field on the line. Each removal also collapses the whitespace it leaves to a single space. - Rebuild as `head.trim_end() + " " + "[fresh:: D]" + ("      [refresh:: N]" if a refresh value is kept) + (" [keeps:: K]" if      a keeps value is kept) + (" [unblocked:: U]" if an unblocked      value is kept) + (" " + suffix if there is a suffix)`. - The kept refresh value is the first valid existing one; `set_refresh` replaces or removes it. Generic stamps keep no `keeps` value (they clear the streak) and no `unblocked` value (they clear the field); the seed preserve-mode keeps the first valid existing `keeps` and omits zero, and never writes `unblocked`. `markUnblockedLine` keeps existing `fresh` / `refresh` / `keeps` and sets `[unblocked:: today]`, replacing any older value. - The suffix bytes themselves are never changed. 4. **No churn.** If the canonical output equals the input byte-for-byte (the line is already stamped today and canonical), report `changed:    false`. 5. **Refusals.** Return the line unchanged with a reason for: - a line that is not a task line; - a recurring task (a `repeat` field in the suffix): its occurrence walks in RECURRING once it arrives (§4), and Tasks would copy the stamp into the next occurrence; - done and cancelled tasks, which callers must not stamp. 6. **Invariance.** For every vector, the Tasks fields both Rust parsers extract are identical before and after: status, dates, priority, recurrence, `id`, `dependsOn`, tags, block ID. So are the Obsidian Tasks fields the JavaScript tests can check. `[unblocked::]` is a run-extending non-Tasks key, so `scheduled`, `dependsOn`, and `id` after it still parse (UB12).

## 4\. Evaluation

Computed at read time, never stored. `today` is the vault's local calendar date (`BOB_NOW` in tests).

```
tags'(t)      = t's tags, lowercased, compared as whole tokens
                (#gtd/pre ≠ #gtd, #pressed_juice ≠ #pre)
checklist(t)  = pre   if #gtd ∈ tags' ∧ #pre ∈ tags'
              | post  if #gtd ∈ tags' ∧ #post ∈ tags' ∧ #pre ∉ tags'
              | none
checklist_scope(t)
              = checklist(t) ≠ none
                ∧ status symbol (as written on the line) ∈ { ' ', '*', '/', '?' }
                ∧ ¬dependency-blocked ∧ ¬(scheduled > today)
                ∧ ¬hidden (the existing #hide predicate, case-insensitive substring, unchanged)
                ∧ path has no _templates / _conflicts segment (existing predicate)
                (recurring, canonical daily note, and Today-linked are NOT exclusions here)
in_scope(t)   = status type TODO ("[ ]") ∧ lane-visible ∧ ¬recurring
                ∧ ¬in a canonical daily note (YYYY/YYYYMMDD.md) ∧ ¬Today(t)
                  lane-visible = the NEXT/PENDING lane predicate on each side: not done,
                  not dependency-blocked, not under _templates or _conflicts,
                  no scheduled date after today, no #hide
                  (see "Tracking review" below)
identity(t)   = exact trailing ^prj → prj; else none
                (^prj-extra, description text, and links/embeds never qualify)
fresh(t)      = latest valid fresh date on the line; none if absent
                (malformed ⇒ ignored + lint; future date ⇒ none + lint)
unblocked(t)  = latest valid unblocked date on the line; none if absent
                (malformed ⇒ ignored + lint)
returned_on(t)= latest of
                  scheduled(t)  if fresh(t) < scheduled(t) ≤ today
                  unblocked(t)  if fresh(t) exists ∧ fresh(t) ≤ unblocked(t) ≤ today
returned_by(t)= scheduled | unblocked  (the source of returned_on; scheduled on a tie)
state(t)      = NEW         if no fresh(t)
              | RESURFACED  if returned_on(t)
              | ROTTEN      if today ≥ fresh(t) + interval(t)
              | FRESH       otherwise
                (Ready only; lane rows keep a null state)
lane(t)       = pending if status symbol "/"
              | next    if status symbol "*"
              | ready   if status type TODO ("[ ]")
              | none    otherwise (Blocked, closed, custom non-TODO)
walk_scope(t) = lane(t) ≠ none ∧ lane-visible ∧ ¬recurring ∧ ¬canonical daily note ∧ ¬Today(t)
lane_interval = pending_interval (pending) | next_interval (next);
                default 1; false = that lane is not walked
interval(t)   = identity(t)=prj with project_interval: project_interval, source project
              | ordinary task in a walked lane: lane interval, source pending | next
              | otherwise: task refresh → note task_refresh → freshness.interval → 7
                (^prj always uses the Ready chain or project interval)
lane_due(t)   = walked ordinary lane ∧ (no fresh(t) ∨ today ≥ fresh(t) + lane_interval)
tracker_due(t)= ^prj lane ∧ walk_scope ∧ (no fresh(t) ∨ resurfaced
                ∨ today ≥ fresh(t) + interval(t)); a disabled lane walk
                never disables ^prj review
due_on(t)     = checklist: none
                ordinary lane row: fresh(t) + lane_interval, or none when unstamped
                ^prj lane row: effective tracker due date, or none when unstamped
                RESURFACED: returned_on(t); ROTTEN/FRESH: fresh(t) + interval(t); NEW: none
due(t)        = checklist_scope(t) ∨ (in_scope(t) ∧ state(t) ≠ FRESH)
tier(t)       = checklist(t) if checklist_scope(t)
              | recurring if recurring_due(t)
              | projects  if due ^prj (Ready or lane; actual lane retained)
              | new       if Ready ∧ state NEW
              | pending   if lane pending ∧ walk_scope ∧ lane_due
              | next      if lane next ∧ walk_scope ∧ lane_due
              | tickler   if state RESURFACED
              | rotten    if state ROTTEN
              | none      otherwise
                (recurring rows never reach lower rules)
```

```
recurring(t)       = t carries a Tasks recurrence (`repeat` field) — the existing predicate on each side
occurs_on(t)       = the earliest of t's valid `scheduled`, `due`, and `start` dates (Tasks' "happens" date);
                     none when it has none of them
recurring_scope(t) = recurring(t) ∧ checklist(t) = none
                     ∧ lane(t) ≠ none                       (status ` `/TODO type, `*`, or `/`; `[?]` and closed rows stay out)
                     ∧ lane-visible                         (the existing predicate: not dependency-blocked, no #hide,
                                                             not under _templates/_conflicts, no scheduled > today)
                     ∧ ¬canonical daily note ∧ ¬Today(t)
recurring_due(t)   = recurring_scope(t) ∧ occurs_on(t) exists ∧ occurs_on(t) ≤ today
tier(t)            = checklist(t)   if checklist_scope(t)              (unchanged; beats everything)
                   | recurring      if recurring_due(t)                 (beats every other rule; trackers included)
                   | the existing rules otherwise (recurring rows never reach them: in_scope/walk_scope keep ¬recurring)
due_on(t)          = occurs_on(t) for recurring rows; days_overdue = today − occurs_on(t) (0 on the day)
state, bucket      = unchanged: null for every recurring row (they stay out of in_scope)
decide             = false for recurring rows; lane and interval fields are reported as today
```

The recurring tier is an overlay applied after the checklist overlay, so that no early return in the evaluator can drop it. In Rust: `evaluate` = `overlay_recurring(overlay_checklist(evaluate_without_checklist(…)))`. The recurring overlay is a no-op whenever the row is a checklist member. It sets `tier`, `due_on`, `days_overdue`, and `decide = false`, and leaves `state`, `lane`, `fresh`, interval, keeps, and lints alone.

The queue holds every row with a tier, in order PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST. Within RECURRING the order is due\_on ↑, path ↑, line ↑ (oldest occurrence first). Other tiers keep their existing comparator:

| Tier | Order within the tier |
| --- | --- |
| pre | path ↑, line ↑ |
| new | path ↑, line ↑ |
| projects | never-confirmed first, due\_on ↑, created ↑, path ↑, line ↑ |
| pending | never-stamped first, due\_on ↑, created ↑, path ↑, line ↑ |
| next | same as pending |
| recurring | due\_on ↑, path ↑, line ↑ |
| tickler | due\_on ↑, path ↑, line ↑ |
| rotten | interval ↑, due\_on ↑, created ↓, path ↑, line ↑ |
| post | path ↑, line ↑ |

A missing created always sorts after dated peers. The commitment tiers are PRE, NEW, PROJECTS, PENDING, NEXT, RECURRING, and TICKLER. They sit before the Commitments done boundary. ROTTEN is upkeep, and POST is the closing tier after ROTTEN. A PROJECTS row never acquires a decay decision for being rotten underneath; ordinary rotten behavior applies to references. Checklist rows resolve only by completion through Tasks and are never stamped by the walk.

Counts keep state and tier distinct:

- `due`, `new`, `resurfaced`, `rotten`, and `fresh` count evaluated Ready states over the full review universe (`due = new +   resurfaced + rotten`), including eligible Ready trackers once each. Pending/Next rows retain null state and never contribute.
- `by_tier` counts the actual full queue with all nine machine tier keys (zeroes for empty tiers); `walk = sum(by_tier.values())`, before any `--limit`.
- `pending_due`, `next_due`, `projects_due`, `pre_due`, `post_due`, and `recurring_due` each equal their tier count, for symmetry.
- `refreshed_today` keeps its meaning.
- New count `upkeep_today`: tasks of any status outside `_templates` / `_conflicts` whose `fresh` equals today and whose status symbol is neither `/` nor `*`.
- `budget_met = budget set ∧ upkeep_today ≥ budget ∧ new == 0` (unchanged raw formula; outstanding commitment tiers take precedence in notices/status mode).

Every `✓` meter displays `upkeep_today`: `✓ N today`, or `✓ N/B today` with a budget.

RESURFACED beats ROTTEN when both hold: a deferral that returned, or an automatic unblock, is due as soon as it returns. The tickler makes a short deferral (for example a P1 roll of 2–7 days) due as soon as it returns, without any hooks write. The `fresh ≤ unblocked` tie is deliberate: a task blocked by a stamped gesture in the morning and unblocked that afternoon still resurfaces. TICKLER rows with the same `due_on` sort by path, then line, so one project's residents are contiguous. An unblock tickler is bucket `rotten` like any tickler, so the `NEW ∪ TICKLER ∪ ROTTEN ∪ READY` partition is unchanged. Tiers never feed buckets or chips: `state`/`bucket` and the partition text below are unchanged. A due `^prj` whose inline date arrived after its stamp is PROJECTS, not TICKLER (PROJECTS beats TICKLER).

**Buckets.** The stable read-time bucket contract for dashboard gating:

```
bucket(t)     = new     if state(t) = NEW
              | rotten  if state(t) = RESURFACED or ROTTEN
              | null    if state(t) = FRESH or null (out of scope)
```

READY applies the visible TODO Ready pool plus `bucket !== "new" && bucket !== "rotten"` — never `state === "fresh"`, which would lose freshness-exempt tasks. A null bucket alone never proves a task is Ready. With the supported plugin and a ready cache, the visible pool partitions as `B = NEW ∪ TICKLER ∪ ROTTEN ∪ READY`, pairwise disjoint (TICKLER is bucket rotten with state resurfaced).

Bucket conformance: S1 maps to `new`; S3, S4, and S11 map to `rotten`; S2, S5, S7, and S12 map to null; every S13 row maps to null.

**Tracking review (projects).** PROJECTS remains the one special tracker group, for visible ^prj tasks. References use ordinary freshness and review groups: Ready refs can be NEW, TICKLER, or ROTTEN; Pending/Next refs use their lane groups; fresh Ready refs do not enter the walk.

- Project identity is the exact trailing block ID prj on a real task. ^prj-extra, description text, and \[\[x\#^prj\]\] links/embeds are never identities; legacy lines without the cosmetic \#prj tag stay supported.
- A ^prj row is reviewed when it does not carry \#hide. bob projects sync owns that tag. A hidden ^prj is out of scope, like any hidden task.
- A \#hide tag hides a reference task like any task. Other exclusions still apply: unsupported/closed/blocked status, dependency blocking, recurring, template/conflict path, canonical daily note, Today membership, and future inline scheduling.
- The checkbox stays authoritative: done/canceled tasks disappear. Creation, import, and sync never auto-confirm; existing confirmation dates are preserved.
- A visible ^prj reviews on project\_interval when set, otherwise the Ready chain refresh → task\_refresh → freshness.interval → 7. It is due in PROJECTS even when Ready state is NEW or RESURFACED. A \[\*\]/\[/\] ^prj keeps its lane and the same cadence; a disabled lane walk never disables PROJECTS. Project trackers never decide and never count keeps.
- References have ordinary freshness, sorting, and keep eligibility. A due Ready reference in NEW or TICKLER is a commitment; once it is ROTTEN it is upkeep and can use the normal keep/decay decision path. Pending/Next references have null state/bucket and use the ordinary lane cadence. No tag, block ID, config field, or special sorting key creates a reference-specific group.

Reference conformance compares tag-only \#ref and legacy ^ref tasks against an otherwise-identical ordinary task: interval/source, state, bucket, tier, due date, overdue days, keeps, and decision eligibility match. An unstamped Ready ref is NEW; a resurfaced ref is TICKLER; an expired Ready ref is ROTTEN and can decide at the keep limit. Lane refs use PENDING/NEXT cadence with null state/bucket; disabled lanes suppress them like ordinary lane tasks. Hidden, blocked, Today-linked, daily-note, template/conflict, and future-scheduled refs remain excluded. ^prj remains the only freshness tracker exception and keeps its project cadence and PROJECTS tier.

**Machine vocabulary (schema 14).** Human and machine output use the nine current groups PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST. The current JSON contract is schema 14. Queue rows gain `returned_by` (`scheduled` | `unblocked`) and, when present, `unblocked_on`. It contains no reference cadence, interval source, tier, or convenience counter. The histogram has exactly these nine keys, including zero values; walk equals the sum of by\_tier before --limit. References remain ordinary tasks in the existing state and tier counts.

Schema history is historical: schemas 6–12 accepted a project and/or reference cadence, and schemas 7–12 included a REFERENCES tier. Schema 14 adds `returned_by` / `unblocked_on` for the unblock tickler. Schema 13 removes the reference-specific fields and redistributes those tasks through the ordinary evaluator. Schema 12 re-keyed ref review identity from exact ^ref to the \#ref tag; schema 11 added recurring, schema 10 renamed returned to tickler, schema 9 added PRE/POST, schema 8 removed the decay calendar gate, and earlier schemas introduced tracker and project fields. Those historical shapes do not describe current output. The shared seed envelope advances with the schema constant; seed behavior is unchanged.

Bob-ledger-tools keeps top-level api v3 and advances api.freshness.version to 12. The current namespace advertises trackerReview, recurringTier, checklistTiers, and `markUnblockedLine`; it no longer advertises referenceReview or refTagIdentity. JS writers call `api.freshness.markUnblockedLine` (namespace ≥ v12). When it is missing, they recover without the field and `console.warn`. Its queue and footer use the nine current groups, and public config/count fallbacks match warm snapshots. Navigation may present a REFERENCES entry only when explicitly marked as legacy from a freshness namespace \<=10; the new producer never creates one, and the compatibility path preserves old loaded-provider rows until plugin reload.

Dashboard freshness.reviewModel NEW/ROTTEN chips still project memoized evaluated states onto the existing visible Ready pool. Hidden review-only rows never feed a badge for a section that excludes them. The status bar footer, \]s, and CLI use the full review counts. The desktop footer shows nonempty groups in walk order with short labels WIP for PENDING, RECUR for RECURRING, and TICKS for TICKLER; it splits TICKLER from ROTTEN so the commitment/upkeep boundary is visible. Dashboard ROTTEN chips still fold TICKLER plus ROTTEN. Review r/N and TIER i/M are queue positions, not completed-task counts. Jump notices and CLI output use full tier names.

**One-release legacy budget key.** A config that still sets `freshness.stale_daily_budget` keeps working for one release: when only the old key is present it supplies the budget, and when both keys are present the canonical `rotten_daily_budget` wins (including an explicit null, which means budget off) while the old value is ignored. Either case emits exactly one `freshness_stale_daily_budget_deprecated` diagnostic per loaded config — in `bob freshness` human/JSON warnings and in the plugin lints — without marking the config invalid. An invalid value for the selected key is still a config error.

Line numbers are 1-based in JSON and docs. Tasks' `lineNumber` is 0-based, so convert it.

**Lints:** `fresh_malformed`, `fresh_future`, `fresh_duplicate`, `fresh_misplaced` (a `fresh`/`refresh`/`keeps`/`unblocked` inside the Tasks suffix; the next stamp repairs it), `refresh_invalid` (per task), `keeps_invalid` (a `keeps` value that is not a decimal integer 1–999; ignored) and `keeps_duplicate` (more than one `keeps` field; the first valid one wins), `unblocked_malformed` (an `[unblocked:: …]` value that is not a valid `YYYY-MM-DD` date; ignored) and `unblocked_duplicate` (more than one `unblocked` field; the latest valid one wins), and `task_refresh_invalid` (per note), `freshness_stale_daily_budget_deprecated` (once per loaded config when the removed `stale_daily_budget` key is present; see above). `today_link_unresolved` passes through unchanged from the Today engine. Checklist lints apply only to open tasks: `checklist_tag_incomplete` (`#pre` or `#post` without `#gtd`), `checklist_tag_conflict` (`#gtd #pre #post`; PRE wins), and `checklist_repeat_not_when_done` (a checklist member whose `repeat` lacks `when done`). `recurring_undated` fires for an open recurring task with no checklist tags, a lane status, outside `_templates`/`_conflicts`, and no valid `scheduled`, `due`, or `start`: "recurring task has no scheduled, due, or start date; the review walk cannot tell when it comes due".

## 5\. Who stamps

**Rule:** a supported human gesture that already rewrites an open task's line stamps that task in the same write, as the last transformation of the line, if the task is still open and non-recurring afterwards. Stamps apply in every lane, because a Next, Pending, or Blocked task may come back to Ready later. A stamp dated today on a canonical line is a no-op.

| Surface | Stamps | Never stamps |
| --- | --- | --- |
| bob-navigation-hotkeys — reverse Blocks stage | Ctrl+Shift+Alt+B, Task Card ⇧B, the palette command, the BLOCKS-line entries, and the BLOCKS chip × all use one writer and stamp each changed dependent T once when its Depends-On line changes, on add and remove, including counted and Task Link gestures | Source N's target-id, lane, and BLOCKS edits, including a minted id and generated mirror; N is never stamped |
| bob-navigation-hotkeys | Alt+F and Ctrl+Alt+F stamp through `api.freshness.keepLine` (counted only under exact eligibility); Alt+N commit and release; the Ctrl+Shift+P Task Card (`1`–`4`, `0`, Enter/Schedule, `b`, `f`, `x`, Ctrl+D; Ctrl+Shift+B is the same card route as `b`) and the stages its actions open, in single, counted and Task Link mode; each open task moved by Ctrl+Shift+M; routed moves stamp like Ctrl+Shift+M; the Ctrl+Enter recommended roll and decay; decision-card outcomes write through the existing writers (Not now and levels through the priority writer, Less often through set-refresh, Reword through the generic stamp, Drop through the cancel row, which never stamps) | the cancel row (including the decay cancel), create-project-note-from-task, the `!` transclusion toggle (a pure toggle that never rewrites a task line) |
| task-status-cycler | Alt+\[ / Alt+\] (including counted and transcluded targets) when the result is an open status, including leaving Blocked by hand; Ctrl+Enter reopening a done task; **picked successor offers** on a Done close (Ctrl+Enter / Alt+\] into Done), which stamp like Ctrl+Shift+Enter | closing the completed task itself (done or cancelled), unpicked recoveries, cancelled closes, Ctrl+Shift+\] bullet → `#task` (that is creation), the dependency-ID normalizer, `recoverBlockedDependents` |
| block-id-prompt | Ctrl+Shift+Enter and `^^` when they rewrite the task line (Ready/Blocked → Next, a new block ID) | unlink, Task Link removal, Ctrl+6 rename |
| `bob capture` | dependency drop on an existing dependent; `plan_task_link` (the link direction of `@route+id!`, Ensure Next, solo `@route:id` / `^route:id`, link-then-close), the `x` rows that set `[/]`, an `&note:id` dependency capture that edits an existing open dependent (its Depends-On line, derived fields, or Blocked status), a `*note:id` capture that edits T (its Depends-On line, derived fields, or Blocked status), **picked `--pull-in` successor offers** (same stamp as a manual link), and each open task an `--inbox-route` answer moves (same stamp as Ctrl+Shift+M) | new tasks on any route (including new tasks with `&` or `*` modifiers), `x` complete of the closed task itself, unpicked successor recoveries, unlink, start rows, sub-bullets, prerequisite target-ID and lane promotion edits, an existing owner's target-ID and lane edits under `*`, an unchanged repeat `&` or `*` |
| Automation | — | hooks, `projects sync`, `task reroll`, `task move`, `gkeep pull`, `ref`, `task archive`, `nightly`, `vault-sync`, `capture-task-id` |
| `bob freshness seed` | the one-time cutover (a documented exception) | — |
| Hand editing | — | not monitored; edit, then press Alt+F |

Deferrals: scheduling a task into the future stamps it, and it becomes Blocked. When it returns it is RESURFACED. Automatic Blocked → Ready returns never stamp: `[unblocked::]` is a freshness input, not a stamp. A **picked successor offer** stamps like a manual link (Ctrl+Shift+Enter): `[fresh:: today]`, clearing `[unblocked::]` and `[keeps::]`. Unpicked recoveries keep the WRITE rule below and never stamp. Automation (hooks, sync, reroll, archive) still never stamps.

**Who writes `[unblocked::]`.** On every automatic transition of an open task from Blocked `[?]` to Ready `[ ]` — reconcile Unblock; capture/cycler close recovery (including `project_member`); nav/cycler project recovery of residents; nav recoveries of lines other than the gesture's own line (the gesture stamps its own line) — evaluated on the recovered line. Skip when any of:

- the row is outside freshness scope (recurring, hidden, daily note, …);
- it has no `[fresh::]` (NEW is already due);
- `fresh(t) < scheduled(t) ≤ today` (already a scheduled tickler);
- the recovery lands in Next or Pending (the lane walk covers it).

Else set `[unblocked:: today]`, replacing any older value. Reconcile prunes `[unblocked:: U]` when `fresh > U`. JS writers call `api.freshness.markUnblockedLine` (namespace ≥ v12).

Placement is the exception to the copy-small-helpers rule: nav, task-status-cycler, and block-id-prompt call `api?.freshness?.stampLine?.(line, dateText) ?? line` on bob-ledger-tools (api `version >= 3`), and nav calls `api.freshness.keepLine` (v5) for explicit keeps, with a source comment at each call site. A missing stamp only means Bryan sees the task once more; a misplaced stamp hides Tasks fields — so the risky part lives in one place, and when ledger-tools is absent or old the gesture simply doesn't stamp. Clicking a freshness mark only reveals the raw field for editing and never stamps.

Before Alt+F or Ctrl+Alt+F stamps a validated Pending (`[/]`) target, it asks once for an optional Work Log summary. In a counted or Task Link batch, only the Pending targets receive the shared summary; every other accepted target still refreshes. Enter on an empty summary still keeps the task and writes no Work Log entry. Escape cancels the refresh, so the task stays due and Ctrl+Alt+F does not advance.

Only bob-navigation-hotkeys writes the Rotten Log (single keeps, counted batches, Task Link sessions, and the decay card's Keep stage). `bob capture`, Rust, hooks, and all other automation never write it: capture's stamps stay generic and clear `keeps` through the same default as any other generic stamp.

## 6\. Review ritual

**Morning:**

1. `[S`, or `]s` from outside the queue when no review task is current, starts at PRE.
2. Complete each PRE chore with Ctrl+Enter or Ctrl+Alt+F, or skip it with `]s`. On the PRE/POST row the walk just landed on, Ctrl+Enter completes it through Tasks (including a still-`[?]` chore) and moves to the next live row of that group with a completion and landing toast. It never leaves PRE: the final chore stays put with `PRE done` and a `]s → …` hint. Ctrl+Alt+F also completes and advances, crossing from PRE into the next tier; Alt+F completes in place. Checklist rows never stamp. Elsewhere, Ctrl+Enter on any landed row completes and advances, but it never crosses the PRE/POST boundary: on the last lane row before POST it stays with a `]s → POST` hint (Ctrl+Alt+F crosses). Keep import is the PRE chore "Import inbox tasks from Google Keep".
3. Walk commitments through NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER with \]s / Ctrl+Alt+F until the notice says Commitments done. N\]s / N\[s move N entries along that queue and wrap with the existing notice, while \[S / \]S stay at the first and last entries. NEW is never capped or skipped. In PROJECTS, replenish the empty project with Ctrl+Alt+F; stamping a project advances to the correct next entry with its block ID and \#hide preserved. References follow the ordinary NEW, lane, TICKLER, and ROTTEN rules as other tasks. In RECURRING the occurrence date has arrived: complete it (Ctrl+Enter), do it today (Ctrl+Shift+Enter), or move its scheduled date past today (Ctrl+Shift+P); recurring rows are never stamped and cancel only through Obsidian Tasks. Ctrl+Alt+F on a landed RECURRING row behaves like Alt+F: it writes nothing, shows the recurring-tier notice, and stays on the row. In the lanes, ask "still in this lane?": keep with Ctrl+Alt+F (Pending asks for an optional Work Log summary; blank Enter still keeps, while Escape leaves the task due), do it today with Ctrl+Shift+Enter (picks a Pomodoro), release with Alt+N. Every answer on a landed row moves on to the next remaining review item in the same keystroke — including a resolving Task Card commit. Ctrl+Shift+M never advances: it moves the task and follows it to its destination note, `<C-o>` returns to the source, and the next `]s` resumes the walk where the moved row was. The walk follows rows by note and line text and moves only from the row just answered, so a move, a Work Log or SCHEDULE LOG insert, or any other line shift never makes an answer or `]s` skip rows or jump ahead. Alt+F is the one answer that stays on the row; `]s` skips without answering, and `<C-o>` returns to the row just answered. For a tickler, "not now" is a priority roll: press Alt+P to take the Task Card's recommended roll or decay in one key. An immediate roll or decay uses the existing Ctrl+Enter path and advances the walk; when the recommendation is cancel, the card appears and a second Alt+P confirms it. A Next or Pending task still offers the optional Work Log summary. Ctrl+Shift+P `1`–`4` remains available to choose a specific priority. Clear CROWDED to 0 (split, sequence, defer, drop via `bob ready`) and choose a highlight (≤3 themes, highlight first). CROWDED does not depend on how far ROTTEN review got.
4. ROTTEN is optional upkeep until 0, the budget, or you stop. The commitments → ROTTEN boundary notice includes `]S closes the review` when POST remains. A counted keep on a due Ready ROTTEN or scheduled TICKLER row asks "Why keep it as written?" and writes one dated Rotten Log entry with the stamp; a blank Enter logs `🤷 no    reason given`, while Esc writes nothing.
5. `]S` jumps to POST; complete Morning review last with Ctrl+Enter or Alt+F. On a multi-row POST checklist, Ctrl+Enter and Ctrl+Alt+F move to the next row, Alt+F stays and counts remaining rows, and the review closes on the last row. POST never wraps.

The Obsidian footer keeps a condensed version of that `]s` notice visible while the cursor is on a review task, including the compact Alt+P roll preview in its hint slot when the row has a recommendation; the full preview detail is in its tooltip. It shows the `]s next` hint otherwise. Its trailing group summary omits the current review group; the full summary shows when no review row is current. Wrap and boundary preambles stay transient in the notice.

**First walk:** release the lanes to their caps.

**Weekly:** if more than about 90% of NEXT reviews end in keep, lengthen `next_interval` to 2–3 and leave Pending at 1.

Review outcomes, one key each (every row except "edit" stamps by itself): still right (Ctrl+Alt+F → next; Alt+F stays); see it less often (Ctrl+Shift+P `f`, then 14 / 30 / 90 → next); not now (Alt+P takes the recommended roll or decay → next; a cancel requires a second press; use Ctrl+Shift+P `1`–`4` to choose a level); do today (Ctrl+Shift+Enter picks a Pomodoro / Alt+N → next); route an inbox task (every non-closing Ctrl+Shift+P answer and Ctrl+Shift+Enter asks where it goes as the last step before writing, then answers → next); route to a project (Ctrl+Shift+M follows the task; \]s resumes); drop (Ctrl+Shift+P `x` → next); sequence (Ctrl+Shift+P `b` → next); wording wrong (edit, then Alt+F stays); already done (Ctrl+Enter → next, never onto a checklist row); recurring occurrence due (Ctrl+Enter done · Ctrl+Shift+Enter today · Ctrl+Shift+P reschedule · `]s` skip — never stamped).

A landing is the exact row `]s` just put the cursor on, in today's walk: the same note, the same line text, and the row still due. An answer shows one toast — what was done, then where the walk landed — except Task Card commits, whose rich notice cards keep their own toast with the landing following on its own. After each advance the walk holds a \~350 ms settle window that swallows double presses; a fast habitual `]s` after the window can still skip a row (`[s` or `<C-o>` recovers it).

The current review task is the row the walk last landed on, and it outlives the landing (cursor moves and other notes). On a relative walk key, when a live current review task exists and is not selected, `]s` / `[s` (and Ctrl+Alt+J/K) first return to it: `[s` returns too, the count is ignored, there is a `<C-o>` jump back, the landing is re-armed, and `[S` / `]S` are unchanged. It ends on a new landing, an answer on its landing, a landed Ctrl+Shift+M park, a stamp or applied decision-card choice on the row itself, an empty queue, the day change or a reload, or when its row is no longer due with the same path and text; a stamp or answer elsewhere does not end it.

A due at-limit task's Alt+F opens the decision card (Not now / Less often / Reword / Drop / Keep) as soon as compatible plugins are loaded. Counted and Task Link sessions skip such tasks with `N needs a decision`. The card's Keep swaps the rows for a reason stage inside the same card: Enter commits stamp, increment, and one Rotten Log entry together, while Esc returns to the choices — no reason in mind means picking Not now, Less often, or Drop instead. See `docs/projects.md` "Approved-decay decision planner".

## 7\. `bob freshness`

Headless review queue (`bob freshness list`, human and JSON) and a guarded, idempotent, staggered cutover seed (`bob freshness seed`) that aborts on any parse change. `seed` is hidden from `bob freshness` help and completion; it stays callable with all its guards and must not be re-run.

Running `bob freshness` with no subcommand runs `list`.

```
bob freshness [-b|--bob-dir DIR] [-f|--format human|json] [-l|--limit N]
bob freshness list [-b|--bob-dir DIR] [-f|--format human|json] [-l|--limit N]
bob freshness seed [-b|--bob-dir DIR] [-d|--dry-run] [-F|--force] [-f|--format human|json]
```

`-b/--bob-dir` selects the vault (default `BOB_DIR`, then `~/bob`). `list` options: `-f/--format human|json` (default `human`) and `-l/--limit N` (queue rows only; counts always cover the whole vault). Human output is colored only on a TTY:

```
bob freshness · Thu 2026-10-08 · every 7d · pending 1d · next 1d · keeps 3

  REVIEW 83 due · 7 pre · 1 new · 2 projects · 10 pending · 15 next · 4 recurring · 14 tickler · 29 rotten · 1 post · ✓ 12 today

  PRE 7
    gtd_daily.md:1      Check weather           Checklist · complete to resolve
  NEW 4
    gkeep_inbox.md:14   Pick up our daughter    created 2026-09-30
    lib/books/paper.md:8 Read the paper         created 2026-09-30
  PROJECTS 2
    home.md:10          Replenish home          Empty project · never confirmed · every 7d (default)
    work.md:44          Staff the launch        Empty project · due today · fresh 2026-10-01 · every 7d (default)
  PENDING 10
    sase.md:40          Land the epic           never confirmed · created 2026-09-20
    work.md:12          Ship the report         due today · fresh 2026-10-07 · every 1d (pending)
  NEXT 15
    …
  RECURRING 4
    recur.md:3          Pay salary              Recurring · 7d overdue · since 2026-10-01
    cash.md:9           Apply for unemployment  Recurring · due today
  TICKLER 14
    b.md:40             Week habits             tickler · scheduled 2026-10-07 · fresh 2026-10-05
    p.md:12             Draft the pitch         tickler · unblocked 2026-10-08 · fresh 2026-10-01
  ── commitments done above · upkeep below ──
  ROTTEN 26
    d.md:1              Water the herbs         due today · fresh 2026-10-07 · every 1d (task)
    a.md:2              Rename queue input      rotten 3d · fresh 2026-09-28 · every 7d (note)
  ── review closeout ──
  POST 1
    gtd_daily.md:20     Morning review          Closeout · complete last
```

- The `REVIEW N due` total is `walk`.
- Human vocabulary says "tickler"; the machine `state` stays `resurfaced`. A TICKLER row reads `unblocked YYYY-MM-DD` when `returned_by` is `unblocked`, else `scheduled YYYY-MM-DD`. There is no reason suffix: the reason is not stored.
- A disabled lane shows `pending off` in the header.
- The header always shows the keep threshold: `keeps 3` when enabled, `keeps 3 · decay off` with decay off, and `keeps 0 · asks every   review` for a zero threshold. Only a capable installed card promises `next review asks`.
- Human rows show `kept N×` when the streak is nonzero and `· decide` where the read-time choice is due.
- Each tier heading carries its count and is omitted when empty.
- The dim divider appears only when rows exist on both sides.
- Lane rows overdue by `n ≥ 1` days read `{n}d overdue`.
- With a budget, the meter reads `✓ 12/15 today`.
- `--limit` truncates rows, never counts.
- Lints go last, unchanged.

`text` is the clean description; queue `line` numbers are 1-based. The seed was a one-time cutover and must not be re-run.

**Fallback.** With a missing, old, or throwing freshness API, dash keeps legacy READY visibility and counts, NEW and both rotten groups render empty, and NEW/ROTTEN badges show `–` (never zero). Native `bob query` has no Obsidian `app.plugins`, so NEW/ROTTEN are empty and READY is ungated there; `bob freshness list` is the headless review interface.

The current JSON contract is schema\_version 14. It includes config interval, pending\_interval/next\_interval, project\_interval, rotten\_daily\_budget, and normalized decay; counts due/new/resurfaced/rotten/fresh, pending\_due/next\_due/projects\_due/pre\_due/post\_due/recurring\_due, a nine-key by\_tier histogram, walk, decide, refreshed\_today, upkeep\_today, budget, and budget\_met; queue rows with the nine current tiers, lane/state/bucket, interval\_source task/note/config/default/pending/next/project, due metadata, keeps, decide, `returned_by`, and `unblocked_on` when present; and warnings. It has no reference interval, references\_due, references tier, or reference interval source. Counts cover the whole vault regardless of --limit. The Obsidian footer abbreviates PENDING as WIP, RECURRING as RECUR, and TICKLER as TICKS; it omits empty groups and the current group. A NEW project counts once in PROJECTS. References follow their ordinary state and lane groups.

Seed options: -d/--dry-run, -F/--force, -f/--format human|json. Ready tasks without a valid `fresh` are grouped by note and bin-packed largest-note-first into 7 buckets; a note bigger than `ceil(total / 7)` splits into consecutive line-order chunks, and ties break by path, then bucket index. Bucket `k` (1–7) lands on `today − 7 + k`, raised per task to `max(bucket date, today − interval(t) + 1, scheduled(t) when due)` so nothing is due on cutover day and nothing arrives RESURFACED, and clamped to today. Every other eligible open, non-recurring task without a valid stamp gets today, including Today-linked tasks. Tasks tagged `#hide` and tasks in daily notes, `_templates`, `_conflicts`, or dot-directories are excluded. "Without a valid stamp" includes missing, malformed, and future-dated `fresh` values.

If candidates remain, the seed refuses when any eligible task already carries a valid `fresh` dated before today (unless `--force`). `--force` bypasses that refusal; it does not rewrite existing valid stamps. A rerun with no candidates reports zero newly stamped tasks, even if bucket stamps are older than today. Later captures remain unconfirmed and should be reviewed normally.

The seed aborts without writing notes when any changed task line would parse differently under either Rust parser. A writing run then re-reads all touched files before the first write and refuses if one changed; `--dry-run` stops after planning and parse validation, without that file recheck. Each file is written through a temp file plus rename. The JSON contract is `schema_version: 14` with `ok`, `date`, `dry_run`, `stamped` (`ready`, `other`), `buckets` (`fresh`, `due_on`, `count`, `notes`), `skipped` (`already_stamped`, `recurring`, `out_of_scope`), `files`, and `warnings`. The shared schema constant also moves the `seed` envelope to 10, with seed content unchanged. Seed candidate selection is unchanged, and list stays read-only. The `buckets` dates describe the initial distribution: `fresh` is the unadjusted bucket date, and `due_on` adds the global interval. Per-task adjustments described above can make the written stamps and actual due dates differ from these summary dates; the report does not list each task's final stamp.

The pre-write guards abort without note changes, but a filesystem failure during the multi-file write can leave earlier files stamped. The error reports the files already written; use the vault's Git history to recover them before retrying. This seed does not use capture's batch rollback mechanism. It does not lock the vault or recheck files between that pre-write validation and their replacement; avoid editing the affected notes during the seed.

Exit codes: 0 on success; 1 for I/O errors and seed refusals; 2 for an invalid `freshness:` block or a non-Dataview task format.

## 8\. Surfaces

| Surface | Phase |
| --- | --- |
| bob freshness | fresh-cli: schema 14 tiered walk PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST; ordinary reference freshness, project cadence for ^prj, recurring occurrences, PRE/POST checklists, approved-decay policy, and `[unblocked::]` ticklers. |
| `bob capture` | capture-stamps (landed: plan\_task\_link + `x` close stamp via `stamp_fresh`; existing-dependent `&` dependency stamp via the same helper; a `*` edit to T stamps T the same way; capture never stamps trackers, a new N, or an existing owner's target-ID and lane edits) |
| bob-ledger-tools | ledger-freshness (landed: api v3 `api.freshness` + status bar in 1.8.0; tracking review under namespace v6 with the `trackerReview` capability; persistent review footer with additive `reviewEntryView` and the compact `api.quickRoll` preview in its hint slot) |
| bob-navigation-hotkeys | nav-review, nav-stamps (landed: Alt+N + Ctrl+Shift+P/Ctrl+Shift+M/! stamping + refresh row in 1.44.0; PROJECTS tier walk with `trackerReview` capability and legacy v3/v4 fallback; `]s` landing notices append the Alt+P recommended-roll preview; additive `api.quickRoll` v1) |
| task-status-cycler | cycler-link-stamps (landed: Alt+\[/Alt+\] + Ctrl+Enter reopen stamping in 1.18.0) |
| block-id-prompt | cycler-link-stamps (landed: Ctrl+Shift+Enter + ^^ stamping in 1.16.0) |
| `rotten.md` (aliases `Review`, `Freshness review`, `Rotten Tasks`) | dash-gating (landed: live summary plus always-present TICKLER and ROTTEN groups; tasks stay in source notes, rows are click-through views) |
| `dash.md` | dash-gating (landed: TODAY → NEW → PENDING → NEXT → READY sections; grouped Review / Work / Browse navigation with NEW/PENDING/NEXT/READY/CROWDED/BLOCKED/ROTTEN/TODAY plus PROJECTS/REFERENCES chips; gated READY with whole-lane tooltip; CROWDED via `noteReady` v1 opening crowded.md; PROJECTS/REFERENCES via `dashboardCollections` v1 opening dash\_projects/dash\_references with Base-contract guards) |
| `crowded.md` + `bob-ready-notes` + heading chips | per-note Ready cap rollout (landed: Crowded Notes page, ranked-bar code block, live `ready n/cap` chips on each note's `## Tasks` heading) |
| freshness mark | fresh-mark (landed: bob-ledger-tools 1.10.0 Live Preview + rendered views) |
| keep-reason prompt (single keeps, counted batches, Task Link sessions) | rotten-log (landed: nav 2.54.0 single keeps, 2.56.0 batches; one prompt asks why, one dated `🥀 **ROTTEN LOG**` entry per counted target) |
| decay-card Keep stage | rotten-log (landed: nav 2.55.0; in-card reason stage, one transaction, Esc back to the choices) |
| freshness mark tooltip rotten line | rotten-log (landed: bob-ledger-tools 1.65.0; `🥀 N keeps logged · last …` in source-note Live Preview + reading views only, never Tasks query rows) |
| note snapshots (`bob` highlights/ref) | rotten-log (open tasks: one condensed `KEPT` row with the newest entry; include-closed snapshots: every entry) |
| Mac idle agenda rotten chip | rotten-log (`🥀 Rotten log` header folding with the other logs, `🥀 N` chip after `⚒ N` / `🗓 N`) |

## 9\. Placement conformance examples

D = `2026-10-08`. Each gives the input line, the expected line, and `changed`. The bob-ledger-tools JavaScript tests use these verbatim.

- **P1 bare:** `- [ ] #task Buy milk` → `- [ ] #task Buy milk [fresh:: 2026-10-08]` (`changed: true`)
- **P2 created:** `- [ ] #task Buy milk [created::2026-09-29]` → `- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]`
- **P3 block ID only:** `- [ ] #task Pick up Abby ^pickup` → `- [ ] #task Pick up Abby [fresh:: 2026-10-08] ^pickup`
- **P4 interleaved suffix:** `- [ ] #task Plan trip [created::2026-08-26] #hide [priority:: high] ^trip` → `- [ ] #task Plan trip [fresh:: 2026-10-08] [created::2026-08-26] #hide [priority:: high] ^trip`
- **P5 unknown field stays left:** `- [ ] #task Read X [[#^h-8bac|🔖]] [h:: e629] [created::2026-08-28]` → `- [ ] #task Read X [[#^h-8bac|🔖]] [h:: e629] [fresh:: 2026-10-08] [created::2026-08-28]`
- **P6 spacing:** `- [ ] #task Rahway  [created:: 2026-07-15]  [scheduled:: 2026-08-10] ^rahway` → `- [ ] #task Rahway [fresh:: 2026-10-08] [created:: 2026-07-15]  [scheduled:: 2026-08-10] ^rahway` (the head collapses to one space; the suffix bytes are untouched)
- **P7 restamp:** `- [ ] #task Buy milk [fresh:: 2026-10-01] [created::2026-09-29]` → the same line with `[fresh:: 2026-10-08]`
- **P8 same day:** `- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]` → byte-identical, `changed: false`
- **P9 misplaced:** `- [ ] #task Buy milk [created::2026-09-29] [fresh:: 2026-10-01]` → `- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]` (the reader reports `fresh_misplaced` on the input)
- **P10 duplicates:** `- [ ] #task A [fresh:: 2026-09-01] B [fresh:: 2026-09-20] [created::2026-09-01]` → `- [ ] #task A B [fresh:: 2026-10-08] [created::2026-09-01]` (the reader reports `fresh_duplicate` and uses 2026-09-20)
- **P11 refresh follows fresh:** `- [ ] #task Rename queue input [refresh:: 14] [created::2026-09-10] [priority:: low]` → `- [ ] #task Rename queue input [fresh:: 2026-10-08] [refresh:: 14] [created::2026-09-10] [priority:: low]`
- **P12 set / clear refresh:** `set_refresh(P2 output, 30)` → `- [ ] #task Buy milk [fresh:: 2026-10-08] [refresh:: 30] [created::2026-09-29]`, and `set_refresh(…, none)` removes it
- **P13 recurring refused:** `- [ ] #task Water plants [repeat:: every week] [created::2026-09-01]` → unchanged, refused as `recurring`
- **P14 global-filter floor:** `- [ ] #task #hide ^x` → `- [ ] #task [fresh:: 2026-10-08] #hide ^x`, and `- [ ] #task #prj Ship it #hide ^prj` → `- [ ] #task #prj Ship it [fresh:: 2026-10-08] #hide ^prj`
- **P15 parenthesized field:** `- [ ] #task Call mom (created:: 2026-09-01)` → `- [ ] #task Call mom [fresh:: 2026-10-08] (created:: 2026-09-01)`
- **P16 indented Blocked:** `\t- [?] #task Deferred [created::2026-09-01] [scheduled:: 2026-10-20]` → `\t- [?] #task Deferred [fresh:: 2026-10-08] [created::2026-09-01] [scheduled:: 2026-10-20]`
- **P17 done refused:** `- [x] #task Old [completion:: 2026-10-01]` → unchanged, refused as `closed`
- **P18 quoted:** `> - [ ] #task Quoted [created::2026-09-01]` → `> - [ ] #task Quoted [fresh:: 2026-10-08] [created::2026-09-01]` (quote markers are stripped for task detection only, mirroring the vault scanner; the `>` bytes stay in place, and more than three leading spaces before `>` is still not a task)

## 10\. State conformance examples

Today `2026-10-08`, the default config (interval 7, both lanes 1), and a Ready, visible, non-recurring task in `a.md` unless noted. The bob-ledger-tools JavaScript tests use these verbatim.

- **S1 new:** no `fresh` → `new`
- **S2 fresh:** `fresh 2026-10-02` → `fresh`, `due_on 2026-10-09`
- **S3 boundary:** `fresh 2026-10-01` → `rotten`, `due_on 2026-10-08`, `days_overdue 0`
- **S4 overdue:** `fresh 2026-09-20` → `rotten`, `due_on 2026-09-27`, `days_overdue 11`
- **S5 task beats note:** `[refresh:: 14]`, note `task_refresh: 3`, `fresh 2026-10-01` → `fresh` (task source, due 2026-10-15)
- **S6 note beats config:** note `task_refresh: 3`, `fresh 2026-10-05` → `rotten` (note source)
- **S7 config:** `freshness.interval: 10`, `fresh 2026-10-01` → `fresh` (config source, due 2026-10-11)
- **S8 invalid overrides fall through:** `[refresh:: 0]` → `refresh_invalid`, and note `task_refresh: soon` → `task_refresh_invalid`; both fall through to config 7
- **S9 malformed:** `[fresh:: 2026-13-01]` → `new` + `fresh_malformed`
- **S10 future:** `[fresh:: 2026-10-09]` → `new` + `fresh_future`
- **S11 resurfaced:** `fresh 2026-10-05`, `scheduled 2026-10-07` → `resurfaced`, `due_on 2026-10-07`
- **S12 not resurfaced:** `fresh 2026-10-07`, `scheduled 2026-10-07` → `fresh`
- **S13 out of scope** (state `null`): a recurring task (its recurring row is undated, so it never reaches RECURRING); a `[?]` with a future `scheduled`; `[*]`; `[/]`; `#hide`; `_templates/x.md`; daily note `2026/20261008.md`; a Ready task linked under today's open Pomodoro; a `[ ]` whose `dependsOn` names an open task. Note that `[*]` and `[/]` keep a null `state` and `bucket` but are in tier scope (see L1).
- **S14 queue order (rewritten).** Given NEW `b.md:3` and NEW `a.md:9`; ROTTEN due 2026-10-01 at `c.md:2`; RESURFACED due 2026-10-07 at `a.md:4`; ROTTEN due 2026-10-07 at `a.md:2`. The order is `a.md:9`, `b.md:3`, `a.md:4` (tickler), `c.md:2`, `a.md:2`.
- **S15 counts (updated):** `refreshed_today` counts a `[*]` and an `[x]` stamped 2026-10-08, but not a Ready task stamped 2026-10-07; `upkeep_today` counts the `[x]` but not the `[*]`. With budget 15, 15 upkeep and 0 new → `budget_met: true`; with 1 new → `false`.
- **Q1 (Bryan's example).** All four tasks are ROTTEN:
- `d.md:1` `[refresh:: 1]`, fresh 10-07, created 09-01;
- `c.md:1` fresh 09-28, created 09-04;
- `b.md:1` fresh 09-28, created 09-01;
- `a.md:1` fresh 09-30, created 09-01. Order: `d.md:1` (A), `c.md:1` (D), `b.md:1` (C), `a.md:1` (B).
- **Q2 (tier order beats path order).** NEW `e.md:1`; `[/]` `d.md:1` fresh 10-07; `[*]` `c.md:1` fresh 10-07; TICKLER `b.md:1` fresh 10-05 scheduled 10-07; ROTTEN `a.md:1` fresh 09-20. Order: e, d, c, b, a, with tiers new, pending, next, tickler, rotten.
- **L1.** A `[*]` stamped 10-08 is in no tier. A `[*]` stamped 10-07 has tier `next`, `due_on` 10-08, `days_overdue` 0, interval 1 from source `next`, and null `state`/`bucket`.
- **L2 (lane overrides refresh).** A `[/]` with `[refresh:: 30]` stamped 10-07 has tier `pending` and interval 1 from source `pending`.
- **L3.** An undated recurring `[*]`, a Today-linked `[*]`, a `[*]` in `2026/20261008.md`, and a `#hide` `[*]` are each in no tier.
- **L4.** With `next_interval: false`, a never-stamped `[*]` is in no tier, and its interval falls back to the Ready chain (7, default). `next_interval:` null means 1.
- **L5 (lane order).** Five `[/]` tasks:
- `z.md:9` never stamped, created 09-01;
- `b.md:1` fresh 10-01, created 09-15;
- `a.md:5` fresh 10-07, created 09-10;
- `a.md:2` fresh 10-07, created 09-20;
- `a.md:1` fresh 10-07, no created. Order: z.md:9, b.md:1, a.md:5, a.md:2, a.md:1.
- **R1 (TICKLER beats older ROTTEN).** TICKLER `b.md:1` (fresh 10-05, scheduled 10-07) comes before ROTTEN `a.md:1` (fresh 09-20, due 09-27).
- **R2 (tickler order).** `x.md:1` scheduled 10-06 created 09-01, `w.md:1` scheduled 10-07 created 09-05, `y.md:1` scheduled 10-07 created 09-01. Order: x, w, y.
- **B1.** 20 lane stamps plus 5 Ready stamps today, budget 15 → `upkeep_today` 5, `refreshed_today` 25, `budget_met: false`. Adding a Blocked `[?]` and an `[x]` stamped today → `upkeep_today` 7.

**Checklist tiers (CL1–CL12).** Today is 2026-10-08. The prefix is `CL`, because `C1–C13` already name config vectors.

- **CL1.** `- [ ] #task #gtd #pre Brush teeth [repeat:: every day when done] [scheduled:: 2026-10-01]` → tier `pre`, lane `ready`, state and bucket null, due, `due_on` null.
- **CL2.** `- [?] #task #gtd #pre Check weather [repeat:: every day when done] [scheduled:: 2026-10-08]` → tier `pre`, lane null, in the queue.
- **CL3.** Each of these has no tier and is absent from the queue: CL2 with an open dependency; CL1 with `[scheduled:: 2026-10-09]`; CL1 plus `#hide`; CL1 under `_templates/`.
- **CL4.** CL1 linked under today's open Pomodoro (Today), and CL1 in canonical daily note `2026/20261008.md` → both still `pre`.
- **CL5.**
- `#pre` without `#gtd` → none, lint `checklist_tag_incomplete`.
- `#gtd #pressed_juice` → none, no lint.
- `#gtd/pre` → none, no lint.
- `#GTD #Pre` → `pre`.
- **CL6.** `#gtd #pre #post` → `pre`, lint `checklist_tag_conflict`.
- **CL7.** One-off, unstamped `- [ ] #task #gtd #post Write retro` → tier `post`, state `new`, bucket `new`. It counts in `new` and `post_due`.
- **CL8.** One task per surviving tier comparator → queue order pre, new, projects, pending, next, tickler, rotten, rotten, post when the example reference and ordinary task are both due. `walk = Σ by_tier`, and `due`/`new`/`rotten` equal the same vault without the checklist rows (except a CL7-style one-off member's own state).
- **CL9.** A recurring checklist row refuses a stamp (Rust `stamp`, JS `stampLine`/`keepLine`). `keeps`, `upkeep_today`, and `refreshed_today` are unchanged, and `decide` is false.
- **CL10.** After completion with the next occurrence inserted above (`- [ ] … [scheduled:: 2026-10-09]`, then `- [x] … [completion:: 2026-10-08]`), neither line is in the queue.
- **CL11.** `[*]` with `#gtd #pre` → `pre` (lane `next`). `#gtd #post … ^ref` → `post`. Checklist beats lane tiers and trackers.
- **CL12.** A `#gtd #pre` row with `[repeat:: every day]` and no `when done` → `pre`, lint `checklist_repeat_not_when_done`.

**Recurring tier (RC1–RC12).** Today `2026-10-08`, default config (interval 7, lanes 1), path `a.md` and Ready `[ ]` unless noted. "→ none" means no tier and `state` null.

- **RC1 arrived:** `- [ ] #task Pay salary [repeat:: every month on the 1st] [scheduled:: 2026-10-01]` → tier `recurring`, lane `ready`, `state`/`bucket` null, `due_on` 2026-10-01, `days_overdue` 7, `decide` false.
- **RC2 arrives today:** the same with `[scheduled:: 2026-10-08]` → `recurring`, `due_on` 2026-10-08, `days_overdue` 0.
- **RC3 not yet:** `[scheduled:: 2026-10-09]` → none (not lane-visible).
- **RC4 due only, past:** `[repeat:: every year] [due:: 2026-10-05]` with no `scheduled` → `recurring`, `due_on` 2026-10-05, `days_overdue` 3.
- **RC5 due only, future:** `[due:: 2026-10-20]` with no `scheduled` → none.
- **RC6 earliest date wins:** `[start:: 2026-10-02] [due:: 2026-10-20]` → `recurring`, `due_on` 2026-10-02, `days_overdue` 6.
- **RC7 undated:** `- [ ] #task Water plants [repeat:: every week]` → none. Rust `bob freshness list` reports warning `recurring_undated` for it. This is also why L3 and S13 (whose recurring rows carry no dates) stay unchanged.
- **RC8 lanes:** a recurring `[*]` and a recurring `[/]`, each with `[scheduled:: 2026-10-01]` → `recurring`, keeping lane `next` / `pending` and null state. Neither is ever `next` or `pending` tier.
- **RC9 exclusions:** a recurring row with `[scheduled:: 2026-10-01]` that is any of the following → none:
- Today-linked;
- in `2026/20261008.md`;
- `#hide`;
- dependency-blocked;
- under `_templates/`;
- `[?]` (not a lane).
- **RC10 checklist wins:** `- [ ] #task #gtd #pre Brush teeth [repeat:: every day when done] [scheduled:: 2026-10-01]` → `pre`, never `recurring`. CL1–CL12 are unchanged.
- **RC11 a stray stamp is ignored:** `[fresh:: 2026-10-08] [scheduled:: 2026-10-01]` on a recurring row → still `recurring`, with null `state`. Stamping that line is still refused.
- **RC12 order and counts:** the RECURRING rows are:
- `b.md:3` scheduled 10-05
- `a.md:9` scheduled 10-05
- `a.md:2` scheduled 10-01

Add NEW `w.md:1`, a `[*]` `n.md:1` stamped 10-07, and TICKLER `t.md:1` (fresh 10-05, scheduled 10-07). The RECURRING order is `a.md:2`, `a.md:9`, `b.md:3`. Counts: - `by_tier.recurring` 3, `recurring_due` 3, `walk` 6; - `due` 2 (NEW + resurfaced; recurring rows add nothing); - `upkeep_today` unchanged.

RC12 full order: `w.md:1` (new), `n.md:1` (next), `a.md:2`, `a.md:9`, `b.md:3` (recurring), `t.md:1` (tickler).

Lints are Rust-only (`bob freshness list` LINTS). They apply only to open tasks, not to done or cancelled lines in archives.

## 10a. Keep-streak conformance vectors (K1–K13)

D = `2026-10-08`. The machine-readable form of every vector below — plus the `C` config, `D` decide, and `B` boundary vectors — is `tests/fixtures/freshness_keeps/vectors.json`, which both languages cite. Rust runs the read/reset/placement cases and has no production increment API; the JS side runs the increment vectors through `api.freshness.keepLine`.

- **K1 absent:** no `keeps` → `keeps 0`, no lints.
- **K2 first valid:** `[fresh:: 2026-10-01] [keeps:: 2]` preserved (seed mode) → `- [ ] #task Rename queue input [fresh:: 2026-10-08] [keeps:: 2]`.
- **K3 refresh/keeps order:** `[keeps:: 2] [refresh:: 14]   [fresh:: 2026-10-01]` on a line with a Tasks suffix canonicalizes to `fresh`, `refresh`, `keeps`, then the suffix.
- **K4 same-day preservation:** a canonical `[fresh:: 2026-10-08]   [keeps:: 2]` line stamped in preserve mode is byte-identical (`changed: false`).
- **K5 generic stamp clears:** the same line stamped generically loses `keeps` (`changed: true`), even same-day.
- **K6 set-refresh clears:** `set_refresh(…, 30)` on a line with `refresh` and `keeps` writes `[fresh:: D] [refresh:: 30]` with no `keeps`.
- **K7 stale stamp clears:** `[fresh:: 2026-10-01] [keeps:: 1]` stamped generically → `[fresh:: 2026-10-08]`, no `keeps`.
- **K8 misplaced:** `[created::…] [keeps:: 2]` reads `keeps 2` with `fresh_misplaced`; the next stamp repairs it to canonical order.
- **K9 duplicates and invalid values:** two `keeps` fields → `keeps_duplicate`, first valid wins; `0`, negative, fractional, `1000`, and non-numeric values → `keeps_invalid` and report 0; an invalid value beside a valid one lints `keeps_invalid` and keeps the first valid value.
- **K10 ceiling and NEW:** `[keeps:: 999]` reads 999 (increments saturate at 999 on the JS side); a never-confirmed task reads `keeps 0`.
- **K11 refusals preserve:** done/cancelled and recurring lines are refused unchanged — the streak stays on the closed line, and there is no write.
- **K12 parser invariance:** for every vector, the Tasks fields both Rust parsers extract are identical before and after, exactly as §9 requires for `fresh`/`refresh`/`keeps`/`unblocked`.
- **K13 exact keys:** similarly named keys (`keep`, `Keep`, `keepsx`) are not `keeps`; the key match is exact and case-sensitive. Readers accept bracket and paren field syntax.

Config vectors C1–C13 pin `decay` normalization (absent, null, `true`, `{}`, `false`, `keeps: 0`, fixed `enter`, and each invalid shape). Decide vectors D1–D8 pin `decide`: at-limit Ready due rows on every local day, below-limit Ready rows, TICKLER coverage, NEW never, `keeps: 0` asking on every due Ready re-confirmation, `decay: false` never asking, and lane rows never deciding. Boundary vectors B1–B5 pin immediate availability on 2026-10-04, 2026-10-08, 2026-10-18, 2026-10-19, and 2026-10-20. Rows have carried `keeps`/`decide` since schema 4, and the current schema is 14; counts carry `decide`; `config.decay` reports normalized `enabled`, `keeps`, and `enter`. CLI human rows show `kept N×` and `· decide` where true; the header explains the threshold or the off state. Counts cover the full queue regardless of `--limit`.

## 10b. UB — unblocked vectors

Today `2026-10-08`. Implementations copy these rows into tests, citing the vector id in a comment. `[unblocked::]` is a run-extending non-Tasks key in the freshness group `[fresh::] [refresh::] [keeps::] [unblocked::]`, then the Tasks suffix.

| \# | situation | outcome |
| --- | --- | --- |
| UB1 | reconcile Unblock of an in-scope Ready recovery with `[fresh:: 2026-10-01]` | writes `[unblocked:: 2026-10-08]`, replacing any older value; `returned_by: unblocked`; `due_on` 2026-10-08; tier TICKLER |
| UB2 | skip NEW: recovered line has no `[fresh::]` | no `[unblocked::]`; already due as NEW |
| UB3 | skip scheduled tickler: `fresh 2026-10-01`, `scheduled 2026-10-07` | no `[unblocked::]`; already RESURFACED from scheduled |
| UB4 | skip Next/Pending landing: recovery lands `[*]` or `[/]` | no `[unblocked::]`; the lane walk covers it |
| UB5 | skip recurring | no `[unblocked::]`; outside freshness scope |
| UB6 | skip hidden (`#hide`) | no `[unblocked::]`; outside freshness scope |
| UB7 | stamp (Alt+F, `stampLine`, `keepLine`, capture `&`) on a line with `[unblocked:: 2026-10-08]` | field removed |
| UB8 | prune: `[fresh:: 2026-10-08] [unblocked:: 2026-10-07]` | reconcile drops `[unblocked::]` (`fresh > U`) |
| UB9 | tie `fresh == U`: `[fresh:: 2026-10-08] [unblocked:: 2026-10-08]` | RESURFACED, `returned_by: unblocked`, `due_on` 2026-10-08 (the `fresh ≤ unblocked` rule) |
| UB10 | both sources: `fresh 2026-10-01`, `scheduled 2026-10-06`, `unblocked 2026-10-07` | `due_on` is the latest (2026-10-07); `returned_by: unblocked` |
| UB11 | both sources tie: `fresh 2026-10-01`, `scheduled 2026-10-07`, `unblocked 2026-10-07` | `due_on` 2026-10-07; `returned_by: scheduled` |
| UB12 | parser invariance | a line with `[unblocked:: 2026-10-08]` before `[scheduled::]`, `[dependsOn::]`, and `[id::]` still parses those Tasks fields identically before and after |
| UB13 | keep on an unblock tickler (`returned_by: unblocked`) | stamps `[fresh:: today]`; `[keeps::]` unchanged (neither increment nor reset); `decide` stays false; `[unblocked::]` cleared by the stamp |
| UB14 | TICKLER tie order | two unblock ticklers with the same `due_on` sort by path ↑, then line ↑ (one project's residents are contiguous); `created` is not a key |
| UB15 | `^prj` whose inline `[scheduled:: 2026-10-07]` arrived after `[fresh:: 2026-10-01]` | RESURFACED into PROJECTS, not TICKLER |

WRITE skips that the recovered line is a canonical daily-note task follow UB5/UB6 (outside freshness scope). Capture/cycler close recovery and nav/cycler project recovery of residents follow UB1 with the same skips.

## 11\. Display: the freshness mark

The freshness mark is display-only and lives only in bob-ledger-tools. Nothing ever writes it: `[fresh:: YYYY-MM-DD]` stays the only stored form, and Rust surfaces already strip inline fields (`note_tasks::clean_description`). The mark is computed at render time from the stamp, the interval, and the shared evaluator that also drives `bob freshness list`, the status bar, Ctrl+Alt+J, and `freshness.md`.

**Principles.** Store absolute, show relative. One glyph lifecycle, borrowed from the status bar: `✓` when confirmed today, a lease ring that drains as the task ages, `⟳` when due (Alt+F turns `⟳` back into `✓`). Loud only when actionable: only tasks the evaluator says are due get color and a capsule. Truthful or neutral: when the plugin cannot identify the exact task, the mark shows only the neutral lease and never a guessed "due"; non-canonical stamps keep their Dataview pill, flagged as needing repair. Reversible and editable: the cursor or a click reveals the raw text, source mode shows raw text, a session toggle restores the old pills, and without bob-ledger-tools the vault falls back to today's pills.

**Anatomy and tones.** A mark is `[glyph][label][interval?]`, rendered at 0.8em in the interface font with tabular numerals, sitting on the text baseline.

| Tone | When | Glyph | Color |
| --- | --- | --- | --- |
| `today` | age 0 on an open task | circle-check (full ring + tick) | `--task-status-next` green |
| `aging` | evaluator says FRESH, or the task is unresolved | lease ring: faint track + arc for the remaining lease | `--text-muted` |
| `due` | evaluator says ROTTEN or RESURFACED | `⟳` (rotate-cw) | `--color-orange`: the only loud tone (14% tinted capsule, 1px inset ring, weight 650) |
| `resting` | evaluator says out of scope (Next, In Progress, Blocked, linked today, …) or the task is closed | lease ring | `--text-faint`, quieter still |

Each tone is also distinguished by its glyph, and `due` by its capsule, so the marks stay readable for color-blind users.

**Label, ring, and interval.** `ageDays = days(fresh → today)`, never negative because future stamps get no mark. The label is `today` at age 0, otherwise `{N}d` (`1d`, `7d`, `282d`); days are always the unit, matching `bob freshness`. The interval is lane-aware per §4: a lane task in a walked lane uses its lane interval, otherwise the existing precedence (task `refresh`, then the note's `task_refresh`, then `freshness.interval`, then 7). `remaining = clamp((interval − ageDays) / interval, 0, 1)`, rounded to 4 decimals: full on the day of confirmation, empty exactly when due (at 0 only the faint track is drawn). The ring starts at 12 o'clock, drawn clockwise for `remaining`. The interval suffix `/{N}d` appears only when the effective interval source is `task`, and never on the `today` tone; note, config, default, and lane intervals stay tooltip-only. `freshnessMarkEveryPhrase` renders the `pending`/`next` sources as `(pending lane)` / `(next lane)`.

**Tooltip.** An `aria-label` with `data-tooltip-position="top"`, lines joined with `\n`, never containing `::`. Dates use fixed English names independent of locale (`Thu, Oct 1`; the year is appended only when it differs from today's year: `Tue, Dec 30, 2025`). Relative age: `today`, `yesterday`, or `N days ago`. `every …` reads `every N days` (or `every 1 day`), plus `(this task)`, `(this note)`, or `(config)` for those sources and nothing for the default, plus `(pending lane)` / `(next lane)` for the lane sources and `(project)` for the project tracker source. Line 1: `Confirmed today` at age 0, otherwise `Confirmed {date} · {relative}`. Line 2 is picked by resolution rather than tone: closed or out of scope → `Not in the review queue: {reason}`; a due lane row → `Daily {PENDING|NEXT} review due since {dueOn} · every …`; a lane row stamped today → `Next review {fresh+interval} · every … ({pending|next} lane)`; a PROJECTS row → `Empty project` with its confirmation/due detail and effective project interval (never a daily lane review or a generic hidden-task exemption); ROTTEN → `Due for review since {dueOn} · every …`; RESURFACED → `Resurfaced {scheduled}: scheduled after it was confirmed`; FRESH or unresolved with the lease running → `Next review {fresh+interval} · every …`; unresolved with the lease over → `Review lease ended {fresh+interval} · every …`. Line 3, `due` tone only: `Alt+F to confirm` (lane rows add the lane keep/release/today keys per M9). Lane and project-tier rows with a tier get the `due` tone and lane rows stamped today get the `today` tone. Lane tasks outside the walk (Today, daily note, disabled lane, recurring, hidden ordinary tasks) keep `resting` with the existing reasons — but a disabled lane never takes a project tracker out of the walk. Reasons by status symbol: `*` Next, `/` In Progress, `?` Blocked, `x`/`X` Done, `-` Cancelled, any other non-space symbol `status [s]`; for `[ ]`, the first that applies: `linked today`, `in a daily note`, `recurring`, `in _templates or _conflicts`, `scheduled for {date}`, otherwise `hidden or dependency-blocked`.

**Eligibility.** A Live Preview task line gets a mark only when `freshnessTaskStatus(line) !== null` (quote-aware), the line has exactly one `fresh` field and it is square-bracketed, and `readFreshness(line, today)` yields a date with none of `fresh_malformed`, `fresh_future`, `fresh_duplicate`, or `fresh_misplaced`. Refresh folding: when the line's only `refresh` field is square-bracketed, starts exactly one space after the `fresh` field, and parses (1–365), it folds into the mark; otherwise it stays a Dataview pill. Space folding: when the character before `[fresh::` is a space, the decoration range starts at that space and the widget restores the gap, so the mark wins over Dataview's widget by position. Lines inside code and lines in source mode never get a mark. Rendered views (a text node) skip the task-line and misplaced checks, because Tasks may have split off the suffix: the node must hold exactly one square-bracketed `fresh` field with a strict, non-future date, with adjacent refresh folding as above; text inside `code`, `pre`, `.bob-fresh-mark`, or `.dataview.inline-field` is never touched.

**Resolution.** Exact first: a memo row with the same `path`, the same 0-based line, and `rawLine === lineText` (Live Preview only), modelled via `freshnessEvaluate`. Then consensus: every memo row in `path` whose own mark source text equals this mark's source text; when at least one candidate exists and all models are deep-equal, use it. Otherwise unresolved: a neutral lease with tone `today` or `aging`, the interval from the line plus `noteFreshnessRawFor(path)` plus config, and `data-resolved="false"`. In Live Preview the line's own status symbol always applies the closed override, even when the memo is a moment behind.

**Interaction and surfaces.** Live Preview: the mark hides while any selection range overlaps the field span (Dataview's inclusive rule), so moving the cursor in shows the raw text; a mousedown on the mark places the cursor at the start of the field and focuses the editor (the mark never writes; Alt+F confirms); marks recompute on edits, viewport and selection changes, the debounced freshness refresh, and the date rollover, with widgets comparing a model key in `eq()` so an unchanged mark never flickers. Rendered views (reading view, Tasks query results such as `dash.md` and `freshness.md`, embeds, hover previews) render when the host renders and refresh when it re-renders. Repair flag: while marks are on, `body.bob-fresh-marks` is set and any leftover `fresh`/`refresh` Dataview pill in Live Preview is a non-canonical stamp, shown at full opacity with a dashed orange border. Session toggle: the "Toggle task freshness marks" command (default on) flips the marks and the body class, refreshes every editor, and shows a Notice.

**Folded keep display (shipped ledger-tools 1.23.0/1.24.0).** From `freshnessMarkModel`, the keep-pip and leaf rendering, and the tooltip builder near the `Alt+F to decide` string (tests `scripts/test-ledger-tools-freshness-keeps.cjs`, `scripts/test-ledger-tools-freshness-decision-card.cjs`):

- Faint filled dots after the label: `--text-faint`, or subdued orange inside a due capsule, never green or red.
- Dot cap: 3 by default; the threshold itself for thresholds 1–2; overflow `+N`; the exact count in the accessible text.
- The leaf replaces `⟳` with `data-decide="true"` only when the nav card capability is present and decay is on.
- Folding: exactly one valid square-bracket `[keeps:: N]` one space after the folded fresh/refresh run is folded. Noncanonical fields keep the dashed repair pill.
- Selection or click reveals the whole raw span and never writes.
- Ambiguous consensus stays neutral, and closed or out-of-scope tasks show dots quietly without a leaf.
- Tooltip wording: `Kept N reviews in a row · Bob asks at L`, the `Alt+F to decide` hint, and counting-only/off wording otherwise (`Kept N reviews in a row` alone without the card capability, `· decay off` with decay off, `· Bob asks every review` for a zero threshold).
- Rotten-log line (shipped ledger-tools 1.65.0): `🥀 3 keeps logged · last Oct 16 — waiting for the weekend…` after the keeps line and before the action line (`🥀 1 keep logged · …` for one entry). The reason truncates to 48 characters with `…`; `🤷 no reason given` shows as is. It appears only where the task's child block is available from the same source document (Live Preview, reading view of the source note); absent, empty, or ambiguous logs show nothing, and Tasks query rows, the dashboard, and `rotten.md` results never show it.

**Live verification (Bryan, in Obsidian).**

- Task lines in Live Preview show `✓ today`, the ring with `Nd`, and `⟳ Nd` in an orange capsule; no Dataview `FRESH` pill appears beside a mark.
- Moving the cursor into a mark, or clicking it, reveals `[fresh:: …]` for editing.
- Alt+F on a `⟳` task flips it to `✓ today` within about a second.
- Ctrl+Shift+P `f` → 14 shows `/14d` from the next day.
- `dash.md` and `freshness.md` Tasks results and reading view show marks; the freshness.md DUE group shows `⟳`.
- A hand-broken stamp (`[fresh:: 2026-13-01]`) shows the dashed repair pill.
- "Toggle task freshness marks" restores the old pills and back again.
- The marks look right in the light and dark themes.
- Metadata Menu does not double-decorate the field.
- Tooltips show their lines. If Obsidian collapses `\n`, record that as a follow-up rather than changing the format.

**Rejected alternatives.** Stored-syntax changes (emoji, compact codes, stored relative ages) would leave Dataview's field index, rot overnight, and rewrite both implementations plus \~540 vault lines. CSS-only restyles cannot compute an age, lease, or review state. Hiding `fresh` entirely loses the due signal. Lane-by-CSS accents contradict the queue for `#hide`, linked-today, and RESURFACED tasks; exact-or-neutral wins.

## 12\. Mark conformance examples

Today `2026-10-08` (Thursday), config interval 7. Each M vector is a Ready, visible, non-recurring task in `a.md` that the evaluator resolves, unless noted. Tooltip lines are separated by `⏎` here; the code joins them with `\n`. The bob-ledger-tools JavaScript tests use these vectors verbatim.

- **M1 today:** `- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]` → text `[fresh:: 2026-10-08]`, foldSpace true; tone `today`, glyph `check`, label `today`, intervalLabel null, remaining 1. Tooltip: `Confirmed today ⏎ Next review Thu, Oct 15 · every 7 days`.
- **M2 aging:** `[fresh:: 2026-10-05]` → age 3, label `3d`, remaining 0.5714, tone `aging`, glyph `ring`. Tooltip: `Confirmed Mon, Oct 5 · 3 days ago ⏎ Next review Mon, Oct 12 ·   every 7 days`.
- **M3 boundary:** `[fresh:: 2026-10-01]` → ROTTEN; tone `due`, glyph `refresh`, label `7d`, remaining 0. Tooltip: `Confirmed Thu, Oct 1 · 7 days ago ⏎ Due for review since Thu, Oct   8 · every 7 days ⏎ Alt+F to confirm`.
- **M4 yesterday:** `[fresh:: 2026-10-07]` → label `1d`, remaining 0.8571. Tooltip: `Confirmed Wed, Oct 7 · yesterday ⏎ Next review Wed, Oct 14 · every   7 days`.
- **M5 folded refresh:** `- [ ] #task Rename queue input [fresh:: 2026-10-05] [refresh:: 14] [created::2026-09-10] [priority:: low]` → text `[fresh:: 2026-10-05] [refresh:: 14]`; label `3d`, intervalLabel `/14d`, remaining 0.7857. Tooltip: `Confirmed Mon, Oct 5 · 3 days ago ⏎ Next review Mon, Oct 19 ·   every 14 days (this task)`.
- **M6 refresh today:** `[fresh:: 2026-10-08] [refresh:: 14]` → tone `today`, label `today`, intervalLabel null. Tooltip: `Confirmed today ⏎ Next review Thu, Oct 22 · every 14 days (this   task)`.
- **M7 note interval:** note `task_refresh: 3`, `[fresh:: 2026-10-06]` → label `2d`, remaining 0.3333, intervalLabel null. Tooltip: `Confirmed Tue, Oct 6 · 2 days ago ⏎ Next review Fri, Oct 9 ·   every 3 days (this note)`.
- **M8 resurfaced:** `- [ ] #task Week habits [fresh:: 2026-10-05] [scheduled:: 2026-10-07]` → tone `due`, glyph `refresh`, label `3d`, remaining 0.5714. Tooltip: `Confirmed Mon, Oct 5 · 3 days ago ⏎ Resurfaced Wed, Oct 7:   scheduled after it was confirmed ⏎ Alt+F to confirm`.
- **M9 (changed).** `- [*] #task Ship it [fresh:: 2026-09-20]` gives tone `due`, glyph `refresh`, label `18d`, remaining 0. Tooltip: `Confirmed Sun, Sep 20 · 18 days ago ⏎ Daily NEXT review   due since Mon, Sep 21 · every 1 day (next lane) ⏎ Alt+F keep · Alt+N   release · Ctrl+Shift+Enter today`.
- **M10 (changed).** `- [*] #task Ship it [fresh:: 2026-10-08]` gives tone `today`. Tooltip: `Confirmed today ⏎ Next review Fri, Oct 9 ·   every 1 day (next lane)`.
- **M11 closed:** `- [x] #task Old [fresh:: 2026-10-08] [completion:: 2026-10-08]` → tone `resting`, glyph `ring`, label `today`. Tooltip: `Confirmed today ⏎ Not in the review queue: Done`.
- **M12 unresolved, running:** as M2 with no resolution → tone `aging`, resolved false, tooltip as M2.
- **M13 unresolved, lease over:** `[fresh:: 2026-09-20]` on `[ ]`, unresolved → tone `aging`, label `18d`, remaining 0. Tooltip: `Confirmed Sun, Sep 20 · 18 days ago ⏎ Review lease ended Sun, Sep   27 · every 7 days`.
- **M14 linked today:** a Ready `[fresh:: 2026-10-05]` task linked under today's open Pomodoro → tone `resting`. Line 2: `Not in the review queue: linked today`.
- **M15 other year:** `[fresh:: 2025-12-30]`, unresolved → label `282d`. Tooltip: `Confirmed Tue, Dec 30, 2025 · 282 days ago ⏎ Review lease ended   Tue, Jan 6 · every 7 days`.
- **M16 quoted:** `> - [ ] #task Quoted [fresh:: 2026-10-05] [created::2026-09-01]` → a mark with foldSpace true.
- **M17 invalid refresh is not folded:** `[fresh:: 2026-10-05] [refresh:: 0]` → text `[fresh:: 2026-10-05]`, interval 7 (default), intervalLabel null; the refresh stays a pill.
- **M18 non-adjacent refresh:** `- [ ] #task X [refresh:: 14] [fresh:: 2026-10-05]` → text `[fresh:: 2026-10-05]`, interval 14 `(this task)`, intervalLabel null.
- **M19.** M9's line with `next_interval: false` gives tone `resting`, line 2 `Not in the review queue: Next`.
- **M20.** `- [/] #task Land it [fresh:: 2026-10-07] [refresh:: 30]` folds the refresh into the mark but shows no `/30d` suffix, because the effective source is `pending`. Tone `due`, label `1d`. Line 2: `Daily PENDING review due since Thu, Oct 8 · every 1 day (pending   lane)`.
- **N1–N6, no mark (source null):**
- N1 malformed `[fresh:: 2026-13-01]`;
- N2 future `[fresh:: 2026-10-09]`;
- N3 duplicate: `- [ ] #task A [fresh:: 2026-09-01] B [fresh:: 2026-09-20] [created::2026-09-01]`;
- N4 misplaced: `- [ ] #task Buy milk [created::2026-09-29] [fresh:: 2026-10-01]`;
- N5 parenthesized: `- [ ] #task Call mom (fresh:: 2026-10-05)`;
- N6 not a task: `- Buy milk [fresh:: 2026-10-05]`.
- **C1–C3, consensus:**
- C1: two `a.md` rows with source `[fresh:: 2026-10-01]`, both Ready and ROTTEN → the `due` model.
- C2: the same pair, but one row is Next → null, so the mark is unresolved.
- C3: no candidate rows → null.
- **MK1 no count:** `[fresh:: 2026-10-05]` with no `keeps` → `keeps 0`, dots null, tooltip as M2 with no keeps line.
- **MK2 aging with 2:** `[fresh:: 2026-10-05] [keeps:: 2]` (FRESH) → dots `••`, tone `aging`; without the card capability the keeps line is counting-only (`Kept 2 reviews in a row`).
- **MK3 due below threshold:** ROTTEN `[fresh:: 2026-09-30] [keeps:: 2]` → dots `••`, glyph `refresh` (not a leaf), line 3 `Alt+F to confirm`.
- **MK4 at-limit leaf:** ROTTEN `[fresh:: 2026-09-30] [keeps:: 3]` with `decide`, the nav card capability, and decay on → glyph `leaf`, `data-decide="true"`, dots `•••`, keeps line `Kept 3 reviews in a row · Bob asks at 3`, line 3 `Alt+F to decide`.
- **MK5 overflow:** `keeps 4` at limit 3 → dots `•••`, overflow `+1` (rendered `•••+1`), keeps line `Kept 4 reviews in a row · Bob asks at 3`.
- **MK6 repair:** duplicate `[keeps:: 2] [keeps:: 3]` or paren `(keeps:: 2)` never folds (`keeps` null); the fields stay visible Dataview pills with the dashed repair styling.

## 13\. Rollout log

There is no freshness trial: no ritual change, release, or tuning waits on a trial window or keep rule; tune intervals or the budget whenever the walk needs it.

- 2026-10-01: per-note CROWDED surfaces (`bob ready`, the dash chip, crowded.md, the heading chips) went live; gesture notices are a separate follow-up.
- 2026-10-01: the tiered morning review walk went live (schema 3, lane intervals, tier notices, walk anchor).
- 2026-10-04: PRE/POST checklist tiers went live (schema 9, ledger 1.29.0 / namespace v7, nav 2.3.0, cycler API v2).
- 2026-10-04: Ctrl+Enter walks the PRE/POST checklist (nav 2.4.0 / nav api v2, cycler 1.25.0).
- 2026-10-06: answering a landed row advances the walk (nav 2.6.1 / nav api v3, cycler 1.26.0, block-id-prompt 1.22.0, ledger 1.29.2).
- 2026-10-06: Ctrl+Shift+M from a landing follows the task to its destination and never advances; the next \]s resumes at the moved row's neighbour (nav 2.7.0).
- 2026-10-06: the walk keeps queue order across line shifts; it follows rows by note and text and moves only from the row just answered (nav 2.7.1).
- 2026-10-06: inbox routing went live — on an open inbox task, every non-closing Ctrl+Shift+P answer and Ctrl+Shift+Enter asks where the task goes before writing, acts then moves without following, and advances the walk as an answer (nav 2.10.0, block-id-prompt 1.23.0).
- 2026-10-08: `]s` / `[s` away from the current review task first return to it; the next press steps from there (nav 2.13.0).
- 2026-10-09: ref review re-keyed from the exact `^ref` block ID to the `#ref` tag (schema 12, ledger 1.37.0 / namespace v10 with `refTagIdentity`): Ready `#ref` rows keep REFERENCES on the reference cadence (never NEW, never decide); `[*]`/`[/]` refs walk PENDING/NEXT as ordinary lane tasks; the `#hide` bypass survives only for exact `^ref` rows until closeout.
- 2026-10-10: closeout removed the transitional hidden-tag bypass for exact reference rows (schema 12, historical shape; ledger 1.39.0 / namespace v10): hidden rows stay out of the walk like any hidden task.
- 2026-10-10: schema 13 and freshness namespace v11 removed the reference cadence and REFERENCES review group. References now use ordinary freshness, lane groups, keep/decay eligibility, and commitment/upkeep rules.
- 2026-10-12: schema 14 and freshness namespace v12 add `[unblocked::]` (returned\_by / unblocked\_on, `markUnblockedLine`). Automatic Blocked → Ready recoveries resurface as TICKLER.
- 2026-10-23: Rotten Log went live — every counted keep asks "Why keep it as written?" and writes one dated `🥀 **ROTTEN LOG**` entry with the stamp (bob-navigation-hotkeys 2.53.0 grammar/reader, 2.54.0 single keeps, 2.55.0 decay-card Keep stage, 2.56.0 counted and Task Link batches, 2.56.1 card header fix; bob-ledger-tools 1.65.0 mark tooltip line; Mac agenda `🥀 N` chip; bob-cli managed-log registry with agenda `log:   "rotten"` and snapshot `KEPT` rows).

## 14\. Keep-streak rollout, rollback, and calibration

Deployed 2026-10-03: bob-navigation-hotkeys 1.69.0 plus bob-ledger-tools 1.24.0 synced byte-identical from the linked source, and `bob` reinstalled from this checkout (schema 5, capture resets). Counting and folded pips went live then; cards, the leaf, decision skip, and the 'next review asks' promise were still gated behind 2026-10-19 at that release.

This release removes that calendar gate: bob-navigation-hotkeys 2.2.0 plus bob-ledger-tools 1.28.0, freshness JSON schema 8, freshness namespace v6, and card capability v2. Decisions are available immediately after installing compatible plugins. Mixed-version sessions fall back: new nav + old ledger counts; new ledger + old/missing nav shows pips without a card promise.

Rollback is config-only and never touches task lines: set `freshness.decay: false` to disable card interception while preserving counting and pips; the session mark toggle restores raw pills. This rollout changed no global config, note intervals, or inbox residence.

Calibration is a lightweight human tally one month after this ungated rollout: decision count, Keep/Drop outcomes, and TICKLER load for two weeks after the first card wave, from counts, decision logs, and git history — no telemetry service or automatic tuning. If most cards (\>50%) are Keep, consider limit 4; if most are Drop, consider limit 2.

Human smoke checklist (no Obsidian UI was available in this headless rollout, so visual verification is still open; automated mark-surface and modal interaction tests pass): light and dark themes, dense lines, narrow widths, Live Preview and reading/Tasks/embeds views, dots and leaf, raw reveal on selection and click, card focus order and key hints, explicit cancellation, and one undo step — on fixture tasks only, never by forcing live task dates or counters.

Rotten Log rollback: the logs remain as plain bullets — no cleanup write exists and none is planned. There is no off-switch; at about 0.6 prompts a morning a plugin rollback is enough. An older nav without the registered grammar would convert the marker into a task during project conversion, so roll forward rather than back.
