# Spec: report-schema ## Purpose Defines the machine report schema — the frozen contract that the `check` skill produces and every downstream component (`clean`, `sweep`, token-estimator, schema-validator) consumes. All field shapes, enum values, and derivation semantics are fixed here; any change requires updating `invariants.md` with explicit human approval. ## Requirements ### Requirement: Top-Level Report Envelope The machine report SHALL be a single JSON object containing `schema_version`, `tool_version`, `generated_at` (ISO-8601 UTC), a `scan` metadata object, a `shortlist` array, an `entries` array, and a `promotion_candidates` array. The `scan` object SHALL include `project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`. The `generated_at` timestamp SHALL be the check time that the clean step uses for its mtime guard. The `promotion_candidates` array SHALL be present (possibly empty) on every report and SHALL list, per candidate, the classifier-judged entry it applies to, the recommended `conventions.json` entry name, and its one-line pitch. #### Scenario: Check writes a well-formed report - **WHEN** the `check` skill completes a scan and classification pass - **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, `entries`, and `promotion_candidates` #### Scenario: Clean reads the check timestamp - **WHEN** the `clean` skill loads a report - **THEN** it reads `generated_at` and uses it as the reference time for the per-op mtime guard #### Scenario: promotion_candidates is always present, possibly empty - **WHEN** a check run has no classifier-judged entry with an applicable unadopted convention - **THEN** `promotion_candidates` is written as an empty array, not omitted ### Requirement: Shortlist Precedes Entries The `shortlist` SHALL contain the project-root-relative paths the deterministic scanner surfaced as candidates. Every `entries[].path` SHALL be a member of `shortlist`. The scanner produces `shortlist`; the AI pass produces `entries`. #### Scenario: Entry path is a shortlist member - **WHEN** the AI pass adds an entry for a file - **THEN** that file's path already appears in `shortlist` #### Scenario: A shortlisted file may be cleared - **WHEN** the AI pass judges a shortlisted file as not stale and not bloated - **THEN** the path remains in `shortlist` but produces no entry in `entries` ### Requirement: Per-File Entry Fields Each entry SHALL contain `path`, `category`, `signals`, `op`, `op_type`, `is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate`. The `op_type` SHALL be one of `deterministic` or `generative`. The `is_destructive` and `is_reversible` fields SHALL be booleans that objectively characterize the chosen `op`. An op SHALL be considered **destructive if and only if it removes or overwrites information that is not preserved elsewhere in the repository**; text-level line removal is not by itself destructive when the information survives (for example a `dedupe` whose removed span is preserved verbatim at `canonical_ref` is non-destructive). The `safety_tier` SHALL be one of `auto` or `confirm` and SHALL be derived (see the Op-Type and Safety-Tier Semantics requirement), not assigned by the model. The `signals` field SHALL list the objective scanner facts that support the classification. #### Scenario: Entry carries the full classification - **WHEN** the AI pass classifies a candidate file - **THEN** the entry includes `path`, `category`, `signals`, `op`, `op_type`, `is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate` #### Scenario: Op-type and safety-tier are constrained enums - **WHEN** an entry is written - **THEN** `op_type` is `deterministic` or `generative` and `safety_tier` is `auto` or `confirm` #### Scenario: Op characterization inputs are present for derivation - **WHEN** an entry is written - **THEN** `is_destructive` and `is_reversible` are present as booleans so `safety_tier` can be computed deterministically from them ### Requirement: Category Taxonomy Is a Closed Enum The `category` field SHALL be an object with `class` and `subtype`. The `class` SHALL be `stale` or `bloat`. When `class` is `stale`, `subtype` SHALL be one of `contradicted`, `orphaned`, `superseded`, `provisional`, `completed-in-place`, or `duplicated`. When `class` is `bloat`, `subtype` SHALL be one of `distill`, `split`, or `freeze`. No other classes or subtypes are permitted. #### Scenario: Stale file is categorized with a stale subtype - **WHEN** a doc references a file that no longer exists - **THEN** its entry has `category.class` = `stale` and `category.subtype` = `orphaned` #### Scenario: Bloated file is categorized with a bloat subtype - **WHEN** a doc is a long resolved-problem narrative that should be condensed - **THEN** its entry has `category.class` = `bloat` and `category.subtype` = `distill` #### Scenario: Unknown subtype is rejected - **WHEN** a report contains a `category.subtype` outside the closed enum - **THEN** the report is invalid ### Requirement: Op-Type Is a Property of the Chosen Op `op_type` SHALL describe the operation the classifier selected and SHALL be consistent with it: it SHALL NOT be a free field, and it SHALL NOT be looked up from `category.subtype` (a single subtype may map to either a deterministic or a generative op depending on the chosen `op`). An entry SHALL include `exact_edit` when, and only when, `op_type` is `deterministic`. An entry with `op_type` = `generative` SHALL NOT include `exact_edit`. This biconditional SHALL be validated deterministically; an entry that violates it is invalid. #### Scenario: Same subtype admits either op-type - **WHEN** a `contradicted` block is recommended for deterministic deletion in one entry and generative rewrite in another - **THEN** both entries are valid, each with `op_type` consistent with its chosen `op` rather than derived from the shared subtype #### Scenario: Op-type and exact-edit consistency is validated - **WHEN** an entry has `op_type` = `generative` but also carries an `exact_edit` (or `op_type` = `deterministic` but omits `exact_edit`) - **THEN** the entry is invalid ### Requirement: Safety-Tier Is Derived Deterministically `deterministic` ops SHALL be exact edits the check pre-computes and the cleaner applies with no model. `generative` ops SHALL be prose transformations requiring a model at clean time. The `safety_tier` SHALL be **computed** by a deterministic script function `derive_safety_tier(op_type, is_destructive, is_reversible, lifecycle=None)` and SHALL NOT be assigned by the model; the report records the computed value. This function remains the single source of truth for tier derivation across the whole report schema, including lifecycle ops — no second tier-deriving function or code path is introduced. For non-lifecycle ops, the function SHALL return `confirm` when `op_type` is `generative`, OR when `is_destructive` is true, OR when `is_reversible` is false; and SHALL return `auto` only when the op is `deterministic` AND non-destructive AND reversible (hence objective). For `delete` and `extract-then-delete` ops, the function SHALL additionally consult the `lifecycle` argument and SHALL return `auto` only when ALL of the following hold: the lifecycle evidence is scanner-proven (a satisfied `served_when_path`, or a temporary-tier retain-recent/age computation) AND the path is tracked AND the worktree at that path is clean at the time of derivation. Every other combination for a lifecycle op — a classifier-judged `served_when`, a dirty worktree, or an untracked path — SHALL derive to `confirm`, regardless of `is_destructive`/`is_reversible` inputs. The function SHALL NEVER return `auto` for a `generative` op, for any non-lifecycle destructive or irreversible op, or for any lifecycle op whose evidence is classifier-judged or whose git state is not tracked+clean, so the model cannot violate invariant #7. `auto`-tier ops SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval; `delete`/`extract-then-delete` `auto` verdicts SHALL additionally be re-verified against live git state at apply time per the `lifecycle-deletion` spec before being applied without a prompt. #### Scenario: Deterministic reversible op derives to auto - **WHEN** an entry has `op_type` = `deterministic`, `is_destructive` = false, and `is_reversible` = true - **THEN** the derived `safety_tier` is `auto` and the cleaner applies the `exact_edit` mechanically without a prompt #### Scenario: Destructive op derives to confirm - **WHEN** an op removes information not preserved elsewhere (`is_destructive` = true, e.g. a `delete-range`) - **THEN** the derived `safety_tier` is `confirm` regardless of `op_type` #### Scenario: Generative op derives to confirm - **WHEN** an entry has `op_type` = `generative` - **THEN** the derived `safety_tier` is `confirm` and the op is delegated to a Sonnet subagent at clean time #### Scenario: Function can never emit auto for a generative or destructive op - **WHEN** `derive_safety_tier(op_type, is_destructive, is_reversible, lifecycle)` is evaluated for any input where `op_type` = `generative`, or (for a non-lifecycle op) `is_destructive` = true, or `is_reversible` = false - **THEN** the result is `confirm`, never `auto` #### Scenario: A lifecycle delete with scanner-proven evidence and tracked+clean state derives to auto - **WHEN** a `delete` entry's `lifecycle` argument shows a satisfied `served_when_path` and the path is tracked and clean - **THEN** the derived `safety_tier` is `auto` #### Scenario: A lifecycle delete with classifier-judged evidence always derives to confirm - **WHEN** a `delete` or `extract-then-delete` entry's `lifecycle` argument carries `served_when` (classifier-judged) - **THEN** the derived `safety_tier` is `confirm`, regardless of tracked/clean state #### Scenario: A lifecycle delete on a dirty or untracked path derives to confirm - **WHEN** a `delete` entry's evidence is scanner-proven but the path is dirty or untracked - **THEN** the derived `safety_tier` is `confirm` ### Requirement: Lifecycle Signal Fields on Shortlist and Entries A shortlist entry or report entry carrying a lifecycle signal SHALL include a `lifecycle` object with `rule_ref` (identifying which rulebook rule matched), `lifetime` (`keep`, `temporary`, or `delete-once-served`), and exactly one of `served_when_path` (deterministic) or `served_when` (classifier-judged, free text), mirroring the rulebook rule's own fields. A directory-rule aggregate entry SHALL carry the same `lifecycle` object shape as a file entry, with its `path` set to the matched directory. #### Scenario: A file-rule match carries the lifecycle object - **WHEN** a file matches a `temporary`-lifetime file rule - **THEN** its shortlist/report entry includes a `lifecycle` object with `rule_ref`, `lifetime: "temporary"`, and no `served_when`/`served_when_path` (temporary entries are keyed on age, not a served signal) #### Scenario: A delete-once-served match carries exactly one served field - **WHEN** a file matches a `delete-once-served` rule using `served_when_path` - **THEN** its `lifecycle` object includes `served_when_path` and omits `served_when`, never both #### Scenario: A directory-rule aggregate entry carries the same shape - **WHEN** a directory rule produces one aggregate shortlist entry - **THEN** that entry's `lifecycle` object has the same fields as a file entry's, with `path` set to the directory ### Requirement: Promotion-Candidates Section Schema The top-level `promotion_candidates` array SHALL contain, per candidate, the `path` of the classifier-judged entry it applies to, the recommended `conventions.json` entry `name`, and the convention's one-line `pitch`. This array SHALL be computed only by the deterministic finalize pass (never the model) and SHALL be empty, not omitted, when no candidate applies. #### Scenario: A promotion candidate references its source entry and convention - **WHEN** an entry with `served_when` has an applicable unadopted convention - **THEN** the `promotion_candidates` array includes an object with that entry's `path`, the convention `name`, and its `pitch` #### Scenario: promotion_candidates is model-free - **WHEN** the finalize pass computes `promotion_candidates` - **THEN** it does so without any subagent dispatch, using only `conventions.json` and the report's own lifecycle entries ### Requirement: Exact-Edit Presence Tied to Op-Type An entry SHALL include an `exact_edit` object when, and only when, `op_type` is `deterministic`. The `exact_edit` SHALL be mechanically applicable and SHALL carry a content fingerprint (`expected_sha256`) so the cleaner's mtime guard can refuse to apply it to a file changed since `generated_at`. Entries with `op_type` = `generative` SHALL omit `exact_edit`. #### Scenario: Deterministic entry includes exact edit - **WHEN** an entry has `op_type` = `deterministic` - **THEN** it includes an `exact_edit` with the edit operation and `expected_sha256` #### Scenario: Generative entry omits exact edit - **WHEN** an entry has `op_type` = `generative` - **THEN** it has no `exact_edit` field and the edit is produced at clean time #### Scenario: mtime guard refuses a stale edit - **WHEN** a file's current content hash differs from the entry's `expected_sha256` - **THEN** the cleaner skips the cached edit and recommends re-analysis ### Requirement: Exact-Edit Kind Is a Closed Enum Every `exact_edit` SHALL carry a `kind` drawn from the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`, `delete`, and `extract-then-delete` — one per deterministic op family the PRD and the lifecycle-aware design name. No other `kind` is permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a fixed inherent `(is_destructive, is_reversible)` characterization that feeds the `safety_tier` derivation, except where noted below that lifecycle kinds' tier also depends on runtime git state and evidence quality (see the Safety-Tier Is Derived Deterministically requirement): - `delete-range` SHALL carry `anchor` with `start_line` and `end_line`; it is destructive and irreversible (derives to `confirm`). - `move-to-archive` SHALL carry `anchor` (`start_line`, `end_line`) and `dest_path`; it is non-destructive and reversible (derives to `auto`). - `insert-frontmatter` SHALL carry the frontmatter `key` and `value` to inject (for example `hygiene: frozen`); it is non-destructive and reversible (derives to `auto`). - `replace-text` SHALL carry `anchor` (`start_line`, `end_line`), a `match` string, and a `replacement` string; it is non-destructive and reversible (derives to `auto`). - `dedupe` SHALL carry `anchor` (`start_line`, `end_line`) of the removed duplicate span and a `canonical_ref` to the kept location; because the removed span is an exact duplicate preserved verbatim at `canonical_ref`, no information is lost, so it is non-destructive and reversible and derives to `auto`. (Contrast `delete-range`, which removes content kept nowhere else, is destructive, and derives to `confirm`.) - `delete` SHALL carry a full-file (or, for a directory-rule aggregate entry, whole-directory) `anchor` and a `lifecycle` object (`rule_ref`, `lifetime`, and exactly one of `served_when_path` or `served_when`); it is destructive (git history is the only recovery path) and its `is_reversible` characterization is inherently git-history-dependent rather than fixed — see the Safety-Tier requirement for how its tier is actually derived. - `extract-then-delete` SHALL carry the same `lifecycle` object as `delete` plus an `extraction_target` classification (`repo-durable` or `cross-repo`) and, for `repo-durable`, a target document reference; it has the same destructive/tier characterization as `delete`, gated additionally on the extraction step succeeding before the delete half applies. A validator SHALL reject an `exact_edit` whose `kind` is outside the closed set or that omits a required sub-field for its `kind`. #### Scenario: Each kind carries its required fields - **WHEN** an entry has `op_type` = `deterministic` with an `exact_edit` of `kind` = `move-to-archive` - **THEN** the `exact_edit` includes `anchor` (`start_line`, `end_line`) and `dest_path`, and the entry is valid #### Scenario: Unknown kind is rejected - **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`, `delete`, `extract-then-delete` - **THEN** the report is invalid #### Scenario: Missing required sub-field is rejected - **WHEN** an `exact_edit` of `kind` = `replace-text` omits its `match` or `replacement` field - **THEN** the report is invalid #### Scenario: Kind characterization feeds the safety-tier derivation - **WHEN** an `exact_edit` has `kind` = `delete-range` - **THEN** the entry's `is_destructive` is true and `is_reversible` is false, so the derived `safety_tier` is `confirm` #### Scenario: delete and extract-then-delete require the lifecycle object - **WHEN** an `exact_edit` has `kind` = `delete` or `kind` = `extract-then-delete` - **THEN** it includes a `lifecycle` object with `rule_ref`, `lifetime`, and exactly one of `served_when_path` or `served_when`; omitting the `lifecycle` object or supplying both `served_when_path` and `served_when` makes the report invalid #### Scenario: extract-then-delete requires an extraction_target - **WHEN** an `exact_edit` has `kind` = `extract-then-delete` - **THEN** it includes `extraction_target` set to `repo-durable` or `cross-repo`, with a target document reference required when `repo-durable` ### Requirement: Per-Entry Token Estimate Each entry SHALL include a `token_estimate`. In v1, only `raw_tokens` (a local-tokenizer count of the removed or reduced span, with no API call) SHALL be required. The `injection_frequency` (`per-session` or `on-demand`) and `weighted_tokens` (`raw_tokens` adjusted by injection frequency) fields SHALL be optional and MAY be `null` or omitted in v1; populating them (injection-frequency weighting plus bottom-up rollup) is the v2 bonus per the PRD build order. When populated, auto-injected files SHALL be weighted as real per-session savings and on-demand docs SHALL be weighted as theoretical-max savings. Roll-up to category and total SHALL be computed bottom-up from the entries. #### Scenario: v1 entry carries only the required raw token count - **WHEN** a v1 check writes an entry without the weighting fields - **THEN** `token_estimate.raw_tokens` is present (a local-tokenizer count, no API call) and `injection_frequency` and `weighted_tokens` may be `null` or omitted #### Scenario: Auto-injected file weighted per session - **WHEN** the weighting fields are populated (v2) and the affected file is auto-injected (for example `CLAUDE.md`) - **THEN** `injection_frequency` = `per-session` and `weighted_tokens` reflects real per-session savings #### Scenario: On-demand doc weighted as theoretical max - **WHEN** the weighting fields are populated (v2) and the affected file is read only on demand - **THEN** `injection_frequency` = `on-demand` and `weighted_tokens` reflects theoretical-max savings ### Requirement: Schema Is a Frozen Contract The report schema SHALL be treated as a frozen contract that every other component consumes. The freeze SHALL be enforced by `invariants.md` (which carries the schema-freeze and `safety_tier` derivation invariants); that file is the authoritative enforcement mechanism for this contract. Any change to a field, an enum value, or a documented semantic SHALL require updating `invariants.md` with explicit human approval before it takes effect. Schema-shape verification SHALL be carried by a schema-validator script and hand-authored **schema fixtures** (one valid machine report, one invalid) under `examples/golden/`; these schema-shape fixtures are distinct from classifier golden examples, which are deferred until the `check` change exists. #### Scenario: Schema change requires invariants update - **WHEN** a contributor proposes adding, renaming, or removing a field or enum value - **THEN** the change is accompanied by an `invariants.md` update with human approval before it takes effect #### Scenario: Validator accepts a well-formed report and rejects a malformed one - **WHEN** the schema-validator script runs against the valid and invalid schema fixtures under `examples/golden/` - **THEN** it accepts the valid machine report and rejects the invalid one #### Scenario: Components rely on the frozen shape - **WHEN** the `clean`, `sweep`, or token-estimator components are built - **THEN** they consume the report without re-deriving its structure