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 */ ]
}
shortlistis the deterministic scanner's candidate set (paths only). Every path inentries[].pathMUST be a member ofshortlist; the scanner produces the shortlist, the AI pass producesentries.generated_atis 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-rangeremoves 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-stampinsert-frontmatter, link-fixreplace-text) are non-destructive and mechanically reversible →auto.deduperemoves a span, but that span is an exact duplicate preserved verbatim atcanonical_ref— no information leaves the repository — so under the refined definition of destructive it is not destructive (and remains reversible) →auto.delete-rangevsdedupe— the contrast that justifies separate kinds. Both remove text, but only one loses information.delete-rangeremoves content kept nowhere else (info lost → destructive →confirm);deduperemoves a span whose content survives atcanonical_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 onexpected_sha256(carried onexact_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_editis present iffop_type == deterministic.op_type == generative⟹ noexact_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-sessionfor auto-injected files (CLAUDE.md, memory index) → counted as real per-session savings;on-demandfor docs read only when opened → theoretical-max savings. May benull(or omitted) in v1; populated by the v2 bonus.weighted_tokens(optional / nullable, v2):raw_tokensadjusted by injection frequency. May benull(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.