cc-os/plugins/os-doc-hygiene/openspec/specs/report-schema/spec.md

20 KiB

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