Contracts

built-output-verifier.md

Contract: Built-Output Verifier

Modules: scripts/docs/seo_verify.py (new), scripts/docs/seo_postprocess.py (modified) Concern: IC-03 Requirements: FR-001, FR-005, FR-008, FR-010, FR-011, FR-012

Asserts against the rendered docs/_site. This is the only layer that can observe a render-path defect — frontmatter can be perfectly correct while the emitted HTML omits the tag entirely, which is exactly today's state for 147 pages.


Part 1 — seo_postprocess.py change

C-B1 — Emit a description tag (FR-005)

Today seo_postprocess.py reads a description (extract_description()) and uses it for Open Graph, Twitter, and structured data — but never writes a <meta name="description">. When DocFX emits none (no frontmatter description), the published page ships with none.

The SEO block gains:

<meta name="description" content="{escaped_desc}">

emitted only when the page has no description tag already, so DocFX's own output stays authoritative where present (single canonical authority — the frontmatter is the author's intent; this is a backstop, not an override).

C-B2 — Idempotence

The existing SEO_BLOCK_RE strip-then-reinsert cycle must remain idempotent. Running the post-processor twice produces identical output. A test asserts this directly — the block is delimited by <!-- spec-kitty-seo:start --> / :end, and duplicate injection would silently double every tag.

C-B3 — Fallback stays visible

The boilerplate fallback remains as a last resort but must be detectable so the source gate can flag it (C-S3). Do not make the fallback indistinguishable from an authored description — that would let the backstop mask the defect it exists to reveal.


Part 2 — seo_verify.py (new)

Public surface

python3 scripts/docs/seo_verify.py --site-dir docs/_site [--strict] [--json REPORT]
FlagBehaviour
(none)Report-only, exit 0
--strictExit non-zero on any violation
--json PATHWrite the AuditRecord (FR-001)

Mirrors the exit contract of description_length_check.py and related_validator.py.

C-B4 — Reuse the indexability predicate (I-08)

Classification imports seo_postprocess.should_index(). It does not reimplement the rule. A second definition of "indexable" would recreate the two-authorities bug this mission exists to fix, one module over.

C-B5 — Rules applied to indexable pages only

RuleAssertion
V-06<meta name="description"> present
V-07Not the boilerplate fallback
V-08<link rel="canonical"> equals the page's own canonical address
V-09og:title matches <title>; og:description matches the description
V-10Description unique across all indexable pages

Titles: non-empty and not equal to the bare site default (NFR-001).

C-B6 — Stub and sitemap invariants (FR-012, I-09)

  • Every REDIRECT_STUB carries noindex.
  • No stub address appears in sitemap.xml.
  • Sitemap entries and indexable pages are the same set.
  • The verifier never mutates _site. It is read-only. A tool that can fix what it checks can pass itself.

C-B7 — Deterministic output (I-06)

Violations sorted by path. Two runs over identical input produce byte-identical reports. Follows the inventory lockfile's established convention.

C-B8 — Records the stale-URL finding (FR-011)

The audit report includes a section noting that the two addresses named in issue #1652 are pre-move addresses now served as stubs, with their current addresses verified. This is what lets the issue be closed on evidence rather than assertion.


Part 3 — Workflow integration

C-B9 — Step position is load-bearing (R-009)

docs-pages.yml order becomes:

docfx build
  → seo_postprocess.py          (injects SEO; stubs do not exist yet)
  → glossary_linker.py
  → redirect_stub_generator.py generate
  → redirect_stub_generator.py coverage
  → seo_verify.py --strict      ← NEW, last
  → upload artifact

The verifier runs last so it observes the final artifact including stubs, and can assert stubs are correctly excluded (C-B6). Placing it before stub generation would leave stub regressions unobserved. The existing ordering comments in the workflow explain why SEO and glossary injection precede stub generation — that ordering is not to be disturbed.

C-B10 — Blocking

Runs with --strict, so a metadata regression fails the build before upload-pages-artifact. A defect must not reach the deployed site.


Test contract (tests/docs/test_seo_verify.py)

TestAsserts
test_missing_description_is_redIndexable page without the tag → violation (V-06)
test_boilerplate_description_is_redFallback string → violation (V-07)
test_wrong_canonical_is_redCanonical pointing elsewhere → violation (V-08)
test_og_mismatch_is_redog:description diverging from description → violation (V-09)
test_duplicate_description_is_redTwo indexable pages sharing a description → both flagged (V-10)
test_stub_is_not_indexableRefresh-stub markup classifies as REDIRECT_STUB, rules skipped
test_stub_absent_from_sitemapNo stub address in the sitemap (C-B6)
test_verifier_does_not_mutate_siteInput tree byte-identical after a run (C-B6)
test_clean_site_is_greenFully compliant fixture → zero violations, exit 0
test_strict_exits_nonzeroExit contract (C-B10)
test_report_is_deterministicTwo runs byte-identical (C-B7)
test_postprocess_emits_descriptionPage with no description tag gains one (C-B1)
test_postprocess_preserves_existing_descriptionExisting DocFX description not overwritten (C-B1)
test_postprocess_is_idempotentTwo passes produce identical output (C-B2)

All operate on synthetic _site fixtures under tmp_path — no DocFX build required, so these stay in the fast tier despite testing build output.

published-page-set-resolver.md

Contract: Published-Page-Set Resolver

Module: scripts/docs/_published_pages.py (new) Concern: IC-01 Requirements: FR-002, FR-003, FR-013, NFR-005

This module is the single authority for "which source pages are published". It exists because that question currently has two answers — docs/docfx.json and a hardcoded list in tests/docs/test_docs_seo.py — which silently diverged.


Public surface

def resolve_published_pages(
    *,
    docs_root: Path,
    docfx_config: Path | None = None,
) -> PublishedPageSet: ...
ParameterMeaning
docs_rootDirectory containing the documentation tree
docfx_configPath to docfx.json; defaults to docs_root / "docfx.json"

Returns a PublishedPageSet (see data-model.md).

Raises

ConditionBehaviour
docfx.json missingFileNotFoundError — fail loud. A missing authority must never degrade to "assume everything" or "assume nothing".
docfx.json unparseableValueError naming the parse failure
Resolved set is emptyValueError — violates I-01
Resolved set below floorValueError naming observed and expected counts — violates I-02

Fail-closed is mandatory. Every error path raises. There is no path on which this function returns a degraded or partial set, because a silently-partial set is the defect under repair.


Behavioural contract

C-R1 — Reads the build's own globs

The build.content[].files patterns are read from docfx.json at call time. They are not duplicated into a module constant. A test asserts that adding a glob to docfx.json changes the resolved set, proving the read is live rather than shadowed.

C-R2 — Honours exclude

docfx.json declares "exclude": ["*/_.md"]. The resolver applies declared excludes; underscore-prefixed pages are not published and must not be gated.

C-R3 — Explicit, reasoned exclusions

Additional exclusions beyond docfx.json's own are enumerated with a reason each (I-04, I-05). At minimum:

PatternReason
archive/**Immutable legacy snapshot; not rewritten for search (C-005)
kitty-specs/**Generated mission-run pages; no human author for a description

Any further exclusion requires a written reason in the same table. An exclusion without a reason is indistinguishable from an oversight.

C-R4 — Non-vacuity floor

A committed floor constant guards against silent under-collection:

MINIMUM_EXPECTED_PAGES: Final[int] = 500

Chosen below the measured 674 so ordinary page churn does not cause false failures, and far above the 16 the broken gate resolves, so the current defect would trip it immediately. Raising this constant is a deliberate act; lowering it requires justification.

> Why a floor rather than an exact count: the repository already retired a hardcoded exact ADR census count (_EXPECTED_CENSUS) on the grounds that it "guards little and merely fails on every legitimate add/remove — pure future friction." A floor captures the real invariant (the set must not collapse) without that friction.

C-R5 — Glob-semantics fidelity

DocFX glob semantics are not Python pathlib semantics. DocFX's context/.md matches recursively including the immediate directory; the naive pathlib translation context//*.md does not match context/foo.md.

This is the single highest-risk detail in the mission. Getting it wrong silently under-collects, which is the exact bug being fixed, wearing a new hat.

Mitigation is empirical, not analytical: a test asserts the resolved count is within a tolerance of the observed 674 and that specific known pages — docs/api/slash-commands.md, docs/guides/install-spec-kitty.md, docs/adr/3.x/2026-07-08-1-mission-resolver-port.md — are members. Reasoning about glob semantics is not accepted as proof; membership assertions are.

C-R6 — Performance

O(n) in tree size, one filesystem walk, one read per file. Must leave headroom inside the 30-second gate budget (NFR-007) alongside the consuming checks.


Test contract

TestAsserts
test_resolves_from_docfx_not_a_constantAdding a glob to a temp docfx.json changes the result (C-R1)
test_underscore_prefixed_pages_excluded_draft.md is absent (C-R2)
test_every_exclusion_carries_a_reasonAll Exclusion.reason non-empty (I-05)
test_empty_resolution_raisesEmpty set raises rather than returning (I-01)
test_below_floor_raisesUnder-collection raises, naming both counts (I-02)
test_missing_docfx_raisesAbsent config raises FileNotFoundError
test_live_tree_membershipThe three known pages above are members (C-R5)
test_live_tree_count_is_realisticLive count ≥ floor and within tolerance of 674 (C-R5)
test_would_have_caught_the_original_regressionA page set built from the retired pre-move globs fails the floor — the regression proof

The last test is the one that matters. It encodes this specific bug so a future reorganisation cannot reproduce it silently.

source-metadata-gate.md

Contract: Source-Level Metadata Gate

Modules: scripts/docs/description_length_check.py (modified), tests/docs/test_docs_seo.py (modified) Concern: IC-02 Requirements: FR-002, FR-003, FR-006, FR-007, NFR-002–NFR-006

Blocks at PR time, runs without .NET. Catches authoring defects before merge; cannot observe the render (that is built-output-verifier.md).


Changes to description_length_check.py

C-S1 — Consume the resolver

Replace the docs_root.rglob("*.md") walk with resolve_published_pages(...). The gate stops guessing which pages are published and asks the authority.

Consequence: pages under docs/plans/, docs/templates/, and other unpublished trees leave the gate's scope. This is correct — publication status is docfx.json's decision. It may reduce the checked count; that reduction is legitimate and must not be confused with the under-collection I-02 guards against.

C-S2 — Retire the ADR exclusion

_EXCLUDE_PREFIXES: Final[tuple[str, ...]] = ("docs/adr/",)

Removed. The accompanying comment is corrected, not deleted (DIRECTIVE_037): it currently cites byte-invariance "enforced by test_adr_content_invariance", which that module's own docstring records as retired on 2026-06-29 (ccd278061). Replace with a note stating the rationale expired and descriptions were backfilled by this mission, so the next reader learns the history rather than finding an unexplained deletion.

Hard ordering constraint: this change must not land before IC-04 completes. Removing the exclusion against un-backfilled ADRs turns CI red for 147 files.

C-S3 — Boilerplate detection (net-new, FR-006)

BOILERPLATE_DESCRIPTIONS: Final[frozenset[str]] = frozenset({
    "Spec Kitty documentation for CLI workflows, governed missions, "
    "AI harnesses, and 3.2 upgrades.",
})

A description matching a known fallback is reported as boilerplate, a distinct reason from missing. Distinct reasons matter: "you wrote nothing" and "you inherited the default" call for different author actions.

Single canonical authority: this set must be imported from, or asserted equal to, seo_postprocess.DEFAULT_DESCRIPTION — not retyped. A test pins the two together so changing the fallback string cannot silently disarm the check.

C-S4 — Uniqueness (net-new, FR-007)

After collecting all descriptions, group by exact value; any group of size > 1 yields one violation per member, each naming its peers (I-07).

Comparison is exact-match on the raw string. Normalisation (case, whitespace) is deliberately not applied — two descriptions differing only in case are still duplicates for search purposes, and exact matching keeps the rule explainable.

C-S5 — Coverage assertion (net-new, FR-003, I-01/I-02)

Before validating, assert the resolved page set is non-empty and above floor. A gate that validates zero pages must fail, not pass.

This single assertion is what makes the class of bug under repair unrepresentable.

C-S6 — Preserve the existing exit contract

--strict exits non-zero on violations; report-only exits 0. Matches related_validator.py. docs-freshness.yml already invokes with --strict; that invocation is unchanged.

C-S7 — Preserve the band

MIN_DESCRIPTION_LENGTH = 50, MAX_DESCRIPTION_LENGTH = 180, inclusive (C-003). Untouched.


Changes to tests/docs/test_docs_seo.py

C-S8 — Delete the hardcoded globs

_published_markdown_files()'s ten-pattern list is removed and replaced by a call to the resolver. This is the direct fix for the 2.4%-coverage defect.

C-S9 — Keep parametrisation

The per-file parametrised shape is retained so a failure names the offending page. Scaling from 16 to ~674 parametrised cases must stay inside the 30-second budget (NFR-007); if it does not, collapse to a single test emitting all violations at once rather than relaxing the budget.


Test contract (NFR-006 — the gate must be provably able to fail)

Extends the existing boundary-proof precedent in test_description_length_gate.py, whose docstring already states the principle: "A length gate that cannot go RED is fake, so the Definition of Done is the boundary proof."

TestAsserts
test_missing_description_is_redAbsent description → violation, reason missing
test_49_and_181_are_redExisting boundary proof preserved
test_50_and_180_are_greenExisting boundary proof preserved
test_boilerplate_description_is_redExact fallback string → reason boilerplate (C-S3)
test_boilerplate_set_matches_seo_postprocessConstant pinned to the render-side fallback (C-S3)
test_duplicate_descriptions_are_redTwo pages, same description → both flagged
test_duplicate_violation_names_the_peerViolation carries the colliding path (I-07)
test_empty_page_set_is_redZero resolved pages → failure, not pass (C-S5)
test_adr_pages_are_now_in_scopeAn ADR without a description is flagged (C-S2)
test_strict_exits_nonzero / test_report_only_exits_zeroExit contract preserved (C-S6)
test_live_tree_is_cleanPost-backfill, the real tree yields zero violations

test_live_tree_is_clean is the acceptance test for IC-04 and will be red until the backfill completes. That is intended and is the red-first signal.