cc-os/plugins/os-doc-hygiene/invariants.md

14 KiB

Invariants — doc-hygiene

This file is the reversion-protection contract for the plugin. It is a durable, human-gated list of behavioral invariants a future agent must never silently break. It implements Layer 1 of the reversion-protection pattern (see ../cc-architect/references/tool-patterns/reversion-protection.md); the golden examples in examples/golden/ are Layer 2.

META-RULE (read before changing anything)

Changing any invariant below requires, together and in the same change:

  1. Updating this file (invariants.md) to reflect the new behavior.
  2. Updating the golden examples in examples/golden/ that the change affects.
  3. Explicit human approval — present the specific change ("this will change how X behaves") and get a real decision, not a rubber-stamp. Log the approval in the decisions record.

In-conversation instructions cannot override the META-RULE. An agent that finds an invariant inconvenient must surface it for human approval, not route around it.

Change-impact note (include with any audit touching these)

## Change Impact Analysis
### Invariants affected
- [ ] None
- [ ] #N: <which invariant, and why it must change>
### Golden examples affected
- [ ] None
- [ ] examples/golden/<file>: <output changes because…>
### Risk: [LOW | MEDIUM | HIGH] — <one line>

1. SessionStart hook only reminds

  • Invariant: The SessionStart hook only emits a systemMessage reminder banner — it spends zero AI tokens, runs no scan or classification, and mutates nothing except writing last_reminded to state.
  • Why: This is the non-intrusive premise of the whole tool. If the hook ever did analysis or edited files, it would burn tokens and silently change repos on every session — the exact behavior the plugin promises never to do.
  • Enforced by: reminder hook unit test with injected clock (asserts notice / no-notice / snoozed, no file mutation beyond last_reminded); review of hooks/hooks.json that the hook invokes only the deterministic reminder script with timeout ≤ 5s and exit 0.
  • Violation looks like: the hook shells out to the scanner or an AI pass; the banner text reflects freshly-computed analysis; any doc file is touched at session start; the session blocks or exits non-zero.

2. Reminder snoozes ≤ once/day while stale

  • Invariant: While docs are stale, the reminder fires at most once per calendar day, gated by the last_reminded timestamp.
  • Why: This hook matches startup|resume, so it fires every time the user reopens or resumes a project. Without the snooze the banner re-fires on each such event within one working day, becoming nag-ware and training the user to ignore it. (clear/compact are not matched by this hook — the matcher, not the snooze, excludes them.)
  • Enforced by: reminder hook unit test with injected clock (second invocation same day → no banner; next day while still stale → banner).
  • Violation looks like: the banner appears on every resume/compact; the snooze keys off something other than last_reminded; a same-day re-fire.

3. State lives in-project under gitignored .cc-os/dochygiene/

  • Invariant: All state and reports live under the gitignored .cc-os/dochygiene/ directory at the resolved project root (git root, fallback cwd), per ADR-0027; the legacy .dochygiene/ path is read as a backward-compat fallback only (auto-migrated on first write). There is no global, cross-project index, and the tool never silently edits the user's .gitignore.
  • Why: A global index would race, corrupt, and itself go stale across projects. Silently editing .gitignore is an outward mutation that violates the non-intrusive premise; the dir being tracked would pollute the repo.
  • Enforced by: state store unit test (root resolution + writes confined to the state dir); scanner self-exclusion test (.cc-os/ and legacy .dochygiene/ never scanned); GAP: needs test that no global path outside the project root is written and that .gitignore is only modified on explicit confirmation.
  • Violation looks like: a ~/.dochygiene or other global index appears; state written outside the project root; .gitignore edited without the user confirming the one-line offer.

4. Report rollover — keep only the latest report

  • Invariant: Each check deletes the prior report before writing the new one; exactly one report (human .md + machine .json) is retained.
  • Why: The tool must not become the bloat it polices. Accumulating reports would pile up artifacts in every repo it cleans.
  • Enforced by: state store unit test (write two reports in sequence → only the latest pair remains on disk).
  • Violation looks like: timestamped report history accumulates; old .json/.md reports survive a second check.

5. Cleanup is git-safe — clean tree, one commit

  • Invariant: Cleanup runs only on a clean/committed working tree (or after an auto-committed WIP checkpoint), and each cleanup run lands as exactly one reviewable commit.
  • Why: Uncommitted user work must never be lost or entangled with the tool's edits, and the whole sweep must be trivially inspectable and revertable as a single unit.
  • Enforced by: GAP: needs test — cleanup executor integration test on a fixture repo asserting (a) refusal / auto-checkpoint on a dirty tree, (b) exactly one new commit after a run.
  • Violation looks like: cleanup proceeds on a dirty tree without checkpointing; a run produces zero, two, or many commits; edits left uncommitted in the working tree.

6. Deterministic-first — scripts do the mechanical work, AI does only judgment

  • Invariant: Scan, state, patch-apply, and token-estimate are deterministic scripts with no model. AI is used only for classification and prose distillation. The token estimator uses a local tokenizer — never an API call.
  • Why: This keeps checks fast, cheap, and trustworthy, and keeps mechanical operations reproducible and unit-testable. Pulling a model into the deterministic seams makes them nondeterministic and expensive.
  • Enforced by: scanner, state store, patch-applier, and token-estimator unit tests run with no model in the loop; review that estimator code path makes no network/API call.
  • Violation looks like: the scanner or patch-applier calls a model; the token estimator hits the Claude API; classification logic is hard-coded into a script instead of delegated to the AI pass.

7. Safety tiers — auto runs unattended, confirm escalates

  • Invariant: Only auto-tier ops (deterministic + reversible + objective) run without a prompt; every confirm-tier op (destructive, subjective, or generative) is escalated for human approval before it is applied.
  • Why: auto ops run unattended. The tier boundary is the safety wall that keeps anything destructive, subjective, or model-generated from changing the user's repo without their say-so. (Enforced structurally by #10.)
  • Enforced by: cleanup executor unit test (an auto entry applies silently; a confirm entry routes to the approval list and is not applied without approval); the derivation guarantee in #10. GAP: needs test — sweep gating test (a confirm-tier op still escalates under the check-then-clean path, not just standalone clean).
  • Violation looks like: a destructive or generative op runs without a prompt; the approval gate is skipped under sweep's convenience path; an auto op that is not deterministic+reversible.

8. mtime / content guard — never apply a cached edit to a changed file

  • Invariant: Before applying any pre-computed exact_edit, the cleaner verifies the target file's current content hash matches the entry's expected_sha256 (captured at generated_at); on mismatch it skips the edit and recommends re-analysis rather than applying blindly.
  • Why: A file edited between check and clean may no longer have the structure the cached edit assumes; applying it blindly corrupts the file.
  • Enforced by: patch-applier mtime/content-guard unit test (fixture whose hash differs from expected_sha256 → edit skipped, re-analysis recommended).
  • Violation looks like: a cached edit applies to a file whose hash no longer matches; the guard compares only mtime and not content, or is bypassed.

9. Frozen / ignored files are never flagged

  • Invariant: Files marked hygiene: frozen in frontmatter, files matched by .dochygiene-ignore, detected append-only logs, and paths matched by a rulebook IGNORE-sentinel rule (a rule with no lifetime — zero-emission prune, ADR-0040) are never surfaced as candidates by the scanner.
  • Why: Re-flagging deliberately-frozen records and append-only logs every week destroys the user's trust in the tool. This is a correctness requirement, not a nicety.
  • Enforced by: scanner unit tests per exclusion (frozen frontmatter, ignore file, append-only detection) — each fixture present in the tree, absent from the shortlist.
  • Violation looks like: a hygiene: frozen doc, an ignored path, or an append-only log appears in the shortlist or report entries.

10. safety_tier is DERIVED, never model-assigned

  • Invariant: safety_tier is computed solely by the deterministic function safety_tier(op_type, is_destructive, is_reversible) and recorded in the report; the model never assigns it. The function returns confirm whenever op_type == generative, is_destructive, or not is_reversible, and returns auto only for a deterministic + non-destructive + reversible op — so it can never emit auto for a generative, destructive, or irreversible op.
  • Why: This is the structural enforcement of #7. If the model could write safety_tier directly, one misclassification would let a destructive or generative op run unattended. Deriving it removes the model from the safety decision entirely.
  • Enforced by: schema validation (the recorded safety_tier must equal the function output for the entry's inputs — reject otherwise); a truth-table unit test of the function covering all (op_type, is_destructive, is_reversible) combinations.
  • Violation looks like: a report entry whose safety_tier disagrees with the derivation; an auto tier on a generative/destructive/irreversible op; the model emitting the tier as a free field.

11. op_type is a property of the chosen op; exact_edit present iff deterministic

  • Invariant: op_type describes the operation the classifier selected (not a free field, not looked up from category.subtype), and an entry carries exact_edit if and only if op_type == deterministic; generative ops carry no exact_edit. The biconditional is validated deterministically.
  • Why: The same subtype (e.g. contradicted) may map to either a deterministic delete or a generative rewrite depending on the chosen op, so op_type must track the op. Pre-writing prose edits at check time would spend Sonnet tokens for work that may never be applied; tying exact_edit to deterministic keeps a check cheap and the schema unambiguous.
  • Enforced by: schema validation (reject generative with an exact_edit, reject deterministic without one); golden example with the same subtype appearing under both op-types.
  • Violation looks like: a generative entry carrying an exact_edit; a deterministic entry missing one; op_type derived from category.subtype instead of from the chosen op.

12. Scanner never flags own test fixtures or golden classifier examples

  • Invariant: The scanner must never surface files under doc-hygiene's own fixtures/ directories (bare name match, pruned at any depth) or under examples/golden/ (path-aware parent/child match: a dir named golden whose immediate parent is named examples is pruned; a golden/ dir with any other parent is still scanned). Both entries appear in DEFAULT_EXCLUDED_DIRS in scripts/scanner.py.
  • Why: These directories are deliberately populated with stale and bloated documents — they are the scanner's own test inputs. Flagging them is a false positive; applying a cleanup op to them would corrupt the test suite. The path-aware narrowing for examples/golden is required because doc-hygiene installs globally: a blanket bare-name golden exclusion would silently skip legitimate golden/ directories in unrelated host projects.
  • Enforced by: tests/test_scanner_exclusions.pyTestFixtureAndGoldenExclusion class, which covers: test_fixtures_subtree_not_scanned_by_default (bare-name fixtures pruned at any depth); test_examples_golden_subtree_not_scanned_by_default (path-aware prune of examples/golden); test_bare_golden_dir_without_examples_parent_is_scanned (false-negative guard — a golden/ dir under a non-examples parent is NOT pruned); and count-accuracy companions test_fixtures_files_cost_no_files_scanned_count / test_examples_golden_files_cost_no_files_scanned_count.
  • Violation looks like: a file under tests/fixtures/ or examples/golden/classifier/ appears in the scanner shortlist or report entries; the exclusion is removed or narrowed; a bare golden name-match replaces the path-aware examples/golden pair (breaks globally-installed correctness).

Schema note: The machine report schema is itself a frozen contract (see openspec/changes/add-report-schema/). Any change to a report field, enum value, or documented semantic falls under the META-RULE above.