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

13 KiB

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

{
  "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

{
  "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

{ "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.