cc-os/plugins/os-doc-hygiene/openspec/changes/archive/2026-06-18-add-report-schema/design.md

250 lines
13 KiB
Markdown
Raw Permalink Normal View History

# 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.