# Design: Deterministic Core

## Context

The report schema (build step #1) is frozen in `openspec/specs/report-schema/`.
This change builds the deterministic substrate beneath it: the three zero-model
runtime components that must exist before the AI `check` pass can run. Per CLAUDE.md
build order item 2, those are the reminder hook, the scanner, and the state store.

Constraints that shape this design:

- **Invariant #6 (deterministic-first):** none of these seams may invoke a model.
  Everything here is scripts with structured JSON output, correct exit codes, and
  injected clock/filesystem for testability.
- **Invariant #1:** the `SessionStart` hook only reminds — no scan, no
  classification, no mutation except writing `last_reminded`.
- **Invariant #2:** the reminder snoozes to at most once per calendar day while
  stale.
- **Invariant #3:** state lives in-project under gitignored `.dochygiene/`; no
  global index; never silently edit `.gitignore`.
- **Invariant #4:** report rollover keeps only the latest `.json` + `.md` pair.
- **Invariant #9:** frozen/ignored/append-only files are never shortlisted.
- **No CC-hook precedent:** no plugin in this collection has wired a Claude Code
  hook. The `systemMessage`-banner mechanism was confirmed against the hook docs
  when the PRD was finalized, but it has never been exercised here — hence the
  build-spike gate.

The scanner's output is **not** the machine report. The frozen schema places
`signals` on `entries`, and `entries` are AI-produced. The scanner emits an
**intermediate** artifact (shortlist + per-path signals); the `check` change folds
that into `report.shortlist` and `entries[].signals`.

## Goals / Non-Goals

**Goals:**

- Wire and prove the `SessionStart` → `systemMessage` banner path (build-spike).
- A deterministic reminder script: staleness comparison, once/day snooze, zero
  tokens, mutate only `last_reminded`, injected-clock testable.
- A deterministic scanner: shortlist + per-path signals, full exclusion pipeline,
  no model, intermediate artifact (not a report).
- A deterministic state store: project-root resolution, lifecycle timestamps,
  atomic writes, report rollover, write confinement, no global index, no silent
  `.gitignore` edits.
- Each seam unit-testable in isolation with injected clock and filesystem.

**Non-Goals:**

- The AI classify pass / `check` skill, `entries`, `op`/`op_type`/`safety_tier`.
- `token_estimate` / `raw_tokens` population (owned by `check`; see proposal).
- The `.gitignore` ignore-entry *offer* UX (owned by `check`). This change only
  guarantees the store never silently edits `.gitignore`.
- The `clean` skill, patch-applier, mtime-guard application, and `sweep`.
- Per-project scope-config overrides beyond the documented defaults (the schema
  records `scope_globs`/`excluded_dirs`; richer override UX is later).

## Decisions

### D1. Three capabilities, one per deterministic seam

`session-reminder`, `doc-scanner`, `state-store` map one-to-one onto the three
runtime components, each with its own independently-testable seam (injected-clock
snooze; exclusion pipeline; root-resolution + rollover + atomic-write). A single
monolithic capability would couple three unrelated concerns and blur which
requirement each test pins. **Alternative considered:** one `deterministic-core`
capability — rejected because the test seams and invariants are disjoint and would
read as an unfocused requirement pile.

### D2. Build-spike is a sequential gate, not a capability

The spike proves the `SessionStart` → `systemMessage` mechanism with a hardcoded
banner before any logic depends on it. The hook-wiring it proves belongs under
`session-reminder` (the capability that owns the hook). It is a **task-level
gate**: nothing else in this change starts until the spike renders visibly in a
real session. The "confirmed to render visibly" criterion is an
**implementation-time acceptance criterion** discharged when the executor runs it —
not at artifact-authoring time. **Alternative considered:** fold the spike into the
reminder hook task — rejected because building snooze/staleness logic atop an
unproven mechanism would conflate a mechanism failure with a logic bug.

### D3. Injected clock and filesystem on every seam

Each component takes a `Clock` (returns "now") and a filesystem/root abstraction
by constructor injection. Tests pass a frozen clock and a temp dir; production
passes the real clock and resolved root. This makes the snooze, staleness, and
rollover behaviors deterministic under test with no real time and no real session.
**Alternative considered:** read `datetime.now()` and `os.getcwd()` inline —
rejected: untestable snooze/staleness, the exact behaviors invariants #1/#2 demand
proof of.

### D4. Project-root resolution: git root, fallback cwd

```
resolve_project_root(start_dir):
    walk upward from start_dir looking for a `.git` directory
    if found:  return that directory          # git root
    else:      return start_dir                # fallback: cwd
```

Resolution is deterministic and pure given `start_dir` and the filesystem. All
state and reports live under `<project_root>/.dochygiene/`. Writes are confined to
that directory (invariant #3); no path outside the resolved root is ever written.
**Alternative considered:** an env var or marker file for the root — rejected as
per-project setup, against US-27 (works without setup beyond enablement).

### D5. Snooze and staleness logic (reminder)

The reminder is a pure decision over `(last_check, last_reminded, now, threshold)`:

```
remind?(state, now, threshold_days):
    stale     = last_check is None OR (now - last_check) > threshold_days
    if not stale:                         return SILENT          # fresh: no banner
    if last_reminded is None:             return BANNER          # never reminded
    if same_calendar_day(last_reminded, now): return SILENT      # snoozed today
    return BANNER                                                # stale, new day
```

On `BANNER` the script writes `last_reminded = now` (the only mutation) and emits
the `systemMessage`. On `SILENT` it writes nothing. **Snooze keys on calendar day,
not a 24h sliding window** — invariant #2 says "once per calendar day," and a
sliding window would let two banners straddle midnight-adjacent sessions. The
snooze is load-bearing because the hook fires on every matched `startup`/`resume`
event; without it, reopening or resuming a project several times in one working day
re-fires the banner each time. (The matcher is `startup|resume`, so `clear`/
`compact` do not trigger the hook at all — the matcher, not the snooze, handles
those; the snooze handles repeated startup/resume within a calendar day.) The
script exits 0 always (never blocks the session) with a low `timeout`
(≤5s) in `hooks.json`. **Alternative considered:** snooze on a rolling 24h delta —
rejected per the calendar-day wording and the midnight edge case.

### D6. Reminder never scans (invariant #1)

The reminder script reads only `state.json`. It does not import or invoke the
scanner, reads no doc content, and computes the banner purely from timestamps. The
banner text is fixed prose plus the day-count and the slash command — it reflects
*no* freshly-computed analysis. This is what keeps the hook zero-token and
side-effect-free beyond `last_reminded`.

### D7. Scanner exclusion pipeline (ordered, short-circuiting)

The scanner walks files matching the scope globs, then applies exclusions so a
file that matches *any* exclusion never reaches the shortlist (invariant #9):

```
for each candidate path under project_root:
  1. dir-prune:      skip if any path component is in excluded_dirs
                     (build, vendor, archive, graphify-out, .dochygiene)  # self-exclusion here
  2. ignore-match:   skip if matched by a .dochygiene-ignore pattern
  3. frozen-frontmatter: skip if YAML frontmatter has `hygiene: frozen`
  4. append-only:    skip if detected as an append-only log
  → otherwise: compute signals, add (path, signals) to the intermediate artifact
```

Dir-pruning happens first and at the directory level so excluded trees are never
descended (cheap, and self-excludes `.dochygiene/` before any state file is read
as a doc). Exclusion precedes signal computation so excluded files cost nothing.
**Append-only detection** is heuristic and deterministic (e.g. monotonic dated/
sectioned growth with no in-place edits across git history, or a documented
append-only marker) — the exact heuristic is an implementation detail, but it must
be deterministic and unit-tested with a positive and a negative fixture.
**Alternative considered:** compute signals first then filter — rejected: wastes
work and risks an excluded file leaking into output if a later filter is missed.

### D8. Scanner emits an intermediate artifact, not a report

The scanner's output is `{ shortlist: [path...], signals: { path: [signal...] } }`
(or an equivalent intermediate shape) — paths plus their objective signals. It does
**not** write a machine report, `entries`, `category`, `op`, or `token_estimate`.
The `check` change reads this artifact and produces the report. Keeping the scanner
output intermediate honors the frozen schema's split (scanner → shortlist; AI →
entries with signals) and keeps this change from re-deriving the report shape.
**Signals are objective facts only** (the schema's "objective scanner facts that
support the classification"); the scanner attaches no judgment.

### D9. Atomic writes (state store)

Every write to `state.json` and to report files is **write-temp-then-rename**: write
to a temp file in the same directory, `fsync`, then `os.replace` onto the target.
`os.replace` is atomic on POSIX, so a concurrent reader (e.g. a second session's
reminder) sees either the old or the new file, never a torn write. **Alternative
considered:** in-place truncate-and-write — rejected: a crash mid-write corrupts
state, and the reminder reads state on every session.

### D10. Report rollover (state store)

```
write_report(json_blob, md_blob):
    delete any existing *.json and *.md report in .dochygiene/   # keep latest only
    atomically write the new .json and .md pair
```

Exactly one report pair survives each write (invariant #4) — the tool must not
become the bloat it polices. Rollover deletes the prior pair *before* writing the
new one. (Report *content* generation is owned by `check`; the store only owns the
rollover-and-atomic-write mechanics, exposed for `check` to call.) **Alternative
considered:** timestamped report history — rejected by invariant #4.

### D11. State store never silently edits `.gitignore`

The store creates `.dochygiene/` (allowed — an untracked, not-yet-ignored dir is
fine; invariant #3 forbids *editing* `.gitignore`, not creating the dir) and
confines all writes to it. It never opens or appends to `.gitignore`. Detecting
whether `.dochygiene/` is ignored and *offering* to add the entry is a `check`-change
concern (US-4 / PRD "State & artifacts"), explicitly out of scope here.

## Risks / Trade-offs

- **[The `systemMessage` banner doesn't render or the hook entry format is wrong
  for this collection]** → The build-spike gate exists precisely for this: a
  hardcoded-banner spike is run and visually confirmed before any logic is built on
  it. If the spike fails, only the spike is reworked, not the reminder logic.
- **[Append-only detection is heuristic — false negatives re-flag a log, false
  positives hide a real doc]** → Deterministic heuristic pinned by positive and
  negative unit fixtures; `.dochygiene-ignore` and `hygiene: frozen` give the user
  explicit overrides for any heuristic miss (invariant #9 has three independent
  exclusion paths, so no single heuristic is the only guard).
- **[Calendar-day snooze across timezones]** → Snooze compares calendar days in a
  single, documented timezone (the injected clock's). Tests pin the boundary
  (same-day → silent, next-day-while-stale → banner). Acceptable for a motivational
  reminder; not a correctness-critical clock.
- **[Cleared-file audit gap]** → A shortlisted file the AI later clears loses its
  scanner signals (they live on `entries`, which a cleared file lacks). This is a
  frozen-schema decision (see report-schema design "Why scanner signals live only
  on entries"), inherited here, not introduced. The scanner's intermediate artifact
  *does* hold per-path signals; whether `check` persists cleared-file signals is its
  call.
- **[Reminder reads state on every session — a corrupt `state.json` could block]** →
  Atomic writes (D9) prevent torn state; the reminder treats a missing/unreadable
  state as "never checked" (stale) and exits 0 regardless, never blocking the
  session (invariant #1).

## Migration Plan

Additive only — no existing behavior changes (no code exists yet). Deploy order is
the task order: spike gate first, then scanner ∥ state-store in parallel, then the
reminder hook (which depends on the state store for `last_reminded`). Rollback is
removing `hooks/hooks.json` and the new scripts; nothing else consumes them yet.

## Open Questions

- **Staleness threshold default** (days before the reminder fires) — a constant for
  this change; the value is an implementation detail, not a spec'd contract.
  Per-project override is a later concern.
- **Exact append-only heuristic** — left to implementation, constrained only to be
  deterministic and fixture-tested (positive + negative).
