# Design: Machine Report Schema

## Overview

This design fixes the literal shape of the **machine report** — the JSON
artifact the `check` skill writes to `.dochygiene/` and that `clean`, `sweep`,
and the token estimator consume. The human-readable report (`.md`) is a
projection of this same data; its shape is **not** specified here — it is a
sibling user-facing contract owned by the upcoming `check` change (load-bearing
for the confirm-tier escalation UX). See proposal.md "Impact".

## Top-Level Envelope

```json
{
  "schema_version": "1.0",
  "tool_version": "0.1.0",
  "generated_at": "2026-06-18T09:52:00Z",
  "scan": {
    "project_root": "/abs/path/to/project",
    "scope_globs": ["**/*.md", "CLAUDE.md"],
    "excluded_dirs": ["build", "vendor", "archive", "graphify-out", ".dochygiene"],
    "files_scanned": 42
  },
  "shortlist": ["docs/old-plan.md", "CLAUDE.md", "README.md"],
  "entries": [ /* one Entry per file the AI classified */ ]
}
```

- `shortlist` is the deterministic scanner's candidate set (paths only). Every
  path in `entries[].path` MUST be a member of `shortlist`; the scanner produces
  the shortlist, the AI pass produces `entries`.
- `generated_at` is the check timestamp used by the clean step's mtime guard.

## Per-File Entry

```json
{
  "path": "CLAUDE.md",
  "category": { "class": "stale", "subtype": "contradicted" },
  "signals": [
    { "name": "broken_reference", "detail": "links scripts/old.py (missing)" },
    { "name": "version_skew", "detail": "pins tool v1, repo on v3" }
  ],
  "op": "Remove the contradicted reference block (lines 40-52).",
  "op_type": "deterministic",
  "is_destructive": true,
  "is_reversible": false,     // delete-range removes in-place content — see kinds table
  "safety_tier": "confirm",   // DERIVED by script from (op_type, is_destructive, is_reversible) — not model-assigned
  "exact_edit": {
    "kind": "delete-range",
    "anchor": { "start_line": 40, "end_line": 52 },
    "expected_sha256": "<hash of file at check time>"
  },
  "token_estimate": {
    "raw_tokens": 180,
    "injection_frequency": "per-session",
    "weighted_tokens": 180
  }
}
```

### Field semantics

| Field | Meaning |
|-------|---------|
| `path` | Project-root-relative path. Member of `shortlist`. |
| `category.class` | `stale` or `bloat`. |
| `category.subtype` | Closed enum (see below). |
| `signals` | Objective facts from the scanner that drove the classification. May be empty for AI-only judgments but SHOULD carry the supporting evidence. |
| `op` | One-line human description of the recommended operation the classifier selected. |
| `op_type` | `deterministic` (exact, pre-computed, no model) or `generative` (prose transform, needs Sonnet at clean time). A **property of the chosen `op`**, not a free field: it is consistent with `op` and with `exact_edit` (see consistency rule below). |
| `is_destructive` | Boolean. An op is **destructive iff it removes or overwrites information that is not preserved elsewhere in the repository.** Text-level line removal is *not* automatically destructive — what matters is whether the information survives (e.g. a `dedupe` removes a span but the content is preserved verbatim at `canonical_ref`, so it is *not* destructive; a `delete-range` removes content kept nowhere else, so it *is*). Characterizes the chosen `op`; supplied by the classifier as an objective property of the op. |
| `is_reversible` | Boolean. `true` when the op can be undone mechanically (e.g. move-to-archive, freeze-stamp). A delete of in-place content is `false`. Characterizes the chosen `op`. |
| `safety_tier` | `auto` (runs without prompt) or `confirm` (escalated). **Derived** by a deterministic script function from `(op_type, is_destructive, is_reversible)` — never freely assigned by the model. The report records the computed value. |
| `exact_edit` | Present iff `op_type == deterministic`. The mechanically-applicable edit plus an integrity hash for the mtime/content guard. |
| `token_estimate` | Per-entry context-weight reduction (see below). |

### Category sub-type enum (closed)

- Stale: `contradicted`, `orphaned`, `superseded`, `provisional`,
  `completed-in-place`, `duplicated`.
- Bloat: `distill`, `split`, `freeze`.

### `exact_edit.kind` is a closed enum (frozen)

Every `exact_edit` carries a `kind` drawn from a **closed** set, one per
deterministic op family the PRD names (PRD "Operation taxonomy" +
US-14: move-to-archive, freeze-stamp, known-target link fix, exact-dup dedupe,
plus delete). Each kind fixes its own required sub-fields and an inherent
`(is_destructive, is_reversible)` characterization. Those two booleans are what
feed the `safety_tier` derivation above — the kind is not a free annotation, it
constrains the characterization, which in turn fixes the tier. A validator can
therefore reject an `exact_edit` whose `kind` is unknown or whose required
fields are missing.

| kind | required fields | is_destructive | is_reversible | derived safety_tier |
|------|-----------------|----------------|---------------|---------------------|
| `delete-range` | `anchor { start_line, end_line }` | true | false | **confirm** |
| `move-to-archive` | `anchor { start_line, end_line }`, `dest_path` | false | true | **auto** |
| `insert-frontmatter` | `key`, `value` (e.g. `hygiene: frozen`) | false | true | **auto** |
| `replace-text` | `anchor { start_line, end_line }`, `match`, `replacement` | false | true | **auto** |
| `dedupe` | `anchor { start_line, end_line }` (removed span), `canonical_ref` (kept location) | false | true | **auto** |

Notes:

- `delete-range` removes in-place content with nothing kept elsewhere, so the
  information is lost → it is destructive **and** irreversible at the op level →
  `confirm`.
- `move-to-archive` (freeze-stamp `insert-frontmatter`, link-fix `replace-text`)
  are non-destructive and mechanically reversible → `auto`.
- `dedupe` removes a span, but that span is an **exact duplicate preserved
  verbatim at `canonical_ref`** — no information leaves the repository — so under
  the refined definition of destructive it is **not destructive** (and remains
  reversible) → `auto`.
- **`delete-range` vs `dedupe` — the contrast that justifies separate kinds.**
  Both remove text, but only one loses information. `delete-range` removes content
  kept nowhere else (info lost → destructive → `confirm`); `dedupe` removes a span
  whose content survives at `canonical_ref` (info preserved → not destructive →
  `auto`). This information-preservation distinction is the whole reason they are
  separate kinds rather than one delete primitive.
- All `anchor`-bearing kinds rely on `expected_sha256` (carried on `exact_edit`,
  not per-kind) for the mtime/content guard.

**PRD US-14 reconciliation (resolved).** `dedupe` is `auto` because
exact-duplicate removal preserves information at `canonical_ref`; consistent with
PRD US-14 (which lists exact-dup dedupe among the no-prompt `auto` ops) and
invariant #7 (`auto` = deterministic + reversible + objective). No divergence
remains.

### `op_type` is a property of the chosen `op` (consistency, not lookup)

`op_type` is **not** a free third field and is **not** looked up from
`category.subtype`. The same subtype can map to either a deterministic delete or
a generative rewrite depending on the op the classifier selects (e.g. a
`contradicted` block can be deterministically deleted, or generatively
rewritten). Therefore `op_type` describes the chosen `op`, and the schema
requires the two to be consistent, checked deterministically:

- `exact_edit` is present **iff** `op_type == deterministic`.
- `op_type == generative` ⟹ no `exact_edit` (the edit is deferred to clean
  time, not pre-written).

A validator enforces this biconditional mechanically; an entry that violates it
(generative with an `exact_edit`, or deterministic without one) is invalid.

### `safety_tier` is DERIVED, never model-assigned

The classifier proposes `op`, `op_type`, and the op's objective
characterization (`is_destructive`, `is_reversible`). It does **not** assign
`safety_tier`. A deterministic script computes it, so the model cannot violate
invariant #7 by mislabeling a tier:

```
safety_tier(op_type, is_destructive, is_reversible):
    if op_type == "generative":         return "confirm"   # generative ⟹ confirm
    if is_destructive:                  return "confirm"   # destructive ⟹ confirm
    if not is_reversible:               return "confirm"   # irreversible ⟹ confirm
    return "auto"                       # deterministic + reversible + objective ⟹ auto
```

**Where "objective" lives.** Invariant #7's `auto` requires
deterministic + reversible + *objective*. Objectivity is not a separate input
because it is implied by construction: a `deterministic` op is an exact,
pre-computed edit (objective by definition), while subjectivity enters the
system only through `generative` ops — which the first branch already forces to
`confirm`. So the three inputs above fully cover invariant #7.

This function can **never** emit `auto` for a generative op or for any
destructive/irreversible op. The only path to `auto` is
deterministic + reversible (hence objective). The truth table below is the
output of this function, not a constraint the model is trusted to satisfy:

| op_type | is_destructive | is_reversible | → safety_tier | Example |
|---------|----------------|---------------|---------------|---------|
| deterministic | false | true | **auto** | move-to-archive, freeze-stamp (insert-frontmatter), known-target link fix (replace-text), exact-dup dedupe (span preserved at `canonical_ref` → not destructive) |
| deterministic | true | (any) | **confirm** | delete-range (info lost, kept nowhere else → destructive → always confirm) |
| deterministic | false | false | **confirm** | irreversible in-place edit |
| generative | (any) | (any) | **confirm** | distill narrative, split file (generative is never auto) |

### Token estimate

```json
{ "raw_tokens": 180, "injection_frequency": "per-session" | "on-demand" | null, "weighted_tokens": 180 }
```

- `raw_tokens` (**required, v1**): local-tokenizer count of the removed/reduced
  span (no API call). This is the only mandatory field in v1.
- `injection_frequency` (**optional / nullable, v2**): `per-session` for
  auto-injected files (CLAUDE.md, memory index) → counted as real per-session
  savings; `on-demand` for docs read only when opened → theoretical-max savings.
  May be `null` (or omitted) in v1; populated by the v2 bonus.
- `weighted_tokens` (**optional / nullable, v2**): `raw_tokens` adjusted by
  injection frequency. May be `null` (or omitted) in v1; populated by the v2
  bonus.
- Roll-up (file → category → total) is computed bottom-up by the estimator and
  is not stored redundantly per entry; the report's consumer sums `entries`.

**v1 vs v2 scope.** Per PRD build-order phase 5, the token estimator
(injection-frequency weighting + bottom-up rollup) is explicitly a **v2 bonus**.
v1 requires only the deterministic `raw_tokens` count (local tokenizer, no API
call — consistent with invariant #6). The weighting fields are part of the
schema shape from the start so the contract never changes, but their population
is deferred to v2.

## Decisions

### Why `category` is a `{ class, subtype }` object, not a flat string

Consumers frequently branch on `class` (stale vs bloat) alone (different remedy
families). Nesting keeps that branch cheap while preserving the precise subtype.

### Why `exact_edit` carries `expected_sha256`

The clean step's mtime guard needs a content fingerprint to refuse applying a
cached edit to a file that changed since `generated_at`. Storing it on the edit
makes the guard a pure function of the report plus the current file.

### Why `safety_tier` is derived, not assigned

Invariant #7 is a safety boundary: `auto` ops run unattended. If the model could
write `safety_tier` directly, a single misclassification would let a destructive
or generative op run without approval. Making the tier a pure function of
`(op_type, is_destructive, is_reversible)` removes the model from that decision
entirely — the model only describes the op's objective properties, and the
script enforces the boundary. This is the difference between trusting the model
to obey invariant #7 and making it structurally unable to violate it.

### Why scanner signals live only on `entries` (cleared-file audit-trail tradeoff)

The deterministic scanner produces `shortlist` (paths only) and the per-entry
`signals` arrive on the AI-produced `entries`. A shortlisted file the AI judges
clean produces no entry (see the "shortlisted file may be cleared" scenario), so
its scanner signals are **not** recorded in the report. This separation is
**intentional**: the scanner contributes facts and a candidate set, while the AI
contributes judgments and entries; the report records decisions, not the raw
scan. The acknowledged tradeoff is an audit-trail gap — there is no record of
*why* a cleared file was shortlisted. The PRD does not require cleared-file
provenance (US-29's audit trail is about clean-time `confirm` approvals, not
check-time clears), so v1 keeps the separation. A future change could attach
per-path scanner signals to `shortlist` (or add a `cleared` list) for
auditability without restructuring `entries`; this note records the decision but
does not change the schema.

### Why generative ops omit `exact_edit`

A check must stay cheap even when no clean follows. Pre-writing prose
transformations would spend Sonnet tokens at check time for work that may never
be applied, so generative edits are produced at clean time instead.
