# 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": "" }, "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.