Contracts

README.md

Contracts — Charter Ownership Consolidation and Neutrality Hardening

This mission is an internal refactor with a small new lint module. It does not introduce new CLI commands or new public HTTP/RPC APIs. The "contracts" below describe the surfaces that this mission either preserves (must not break) or introduces (must be stable going forward).

Each contract is a testable specification of behavior, not a prose description.


Index

#ContractKindFile
C-1Charter public import surface — baseline preservationImport APIcharter-public-import-surface.md
C-2Shim deprecation warning — emission contractRuntimeshim-deprecation-contract.md
C-3Neutrality lint pytest — discovery & failure-output contractTest harnessneutrality-lint-contract.md
C-4Banned-terms YAML — v1 schemaConfig filebanned-terms-schema.yaml
C-5Language-scoped allowlist YAML — v1 schemaConfig filelanguage-scoped-allowlist-schema.yaml
C-6Charter ownership invariant — enforced at CITest harnesscharter-ownership-invariant-contract.md

How these map to spec requirements

ContractCovers
C-1FR-007 (CLI behavioral invariance via stable import API), NFR-005 (no startup regression)
C-2FR-005 (DeprecationWarning catchable), NFR-004 (standard warnings category)
C-3FR-010, FR-011 (regression test mechanics + error messages)
C-4FR-008, FR-014 (banned-term enforcement, single-file maintenance)
C-5FR-009, FR-013 (allowlist existence, Python guidance scope)
C-6FR-001, FR-002, SC-001 (canonical ownership invariant)

banned-terms-schema.yaml

C-4 — Banned Terms YAML v1 Schema

#

This file is a YAML-formatted schema description, NOT the production banned_terms.yaml

that ships in src/charter/neutrality/. It documents the contract that implementations

must satisfy. A JSON Schema representation may be added in a future mission if needed.

#

Covers: FR-008, FR-014

$schema: "https://json-schema.org/draft/2020-12/schema" $id: "contracts:banned-terms.v1" title: "Banned Terms Config v1" description: > Declares regex and literal terms that the neutrality lint rejects in generic-scoped doctrine artifacts. Contributors edit this file directly to extend the ban list (NFR-006 self-documenting).

type: object required: [schema_version, terms] additionalProperties: false

properties: schema_version: type: string const: "1" description: "Version gate; v2 may introduce breaking schema changes."

terms: type: array minItems: 1 description: "The list of banned-term entries. Empty list disables the lint entirely — if that is desired, delete the lint test instead." items: type: object required: [id, kind, pattern, rationale] additionalProperties: false

properties: id: type: string pattern: "^[A-Z]{2,4}-\\d{3}$" description: "Stable unique identifier. Convention: LANG-NNN (e.g., PY-001). MUST be unique within terms[]."

kind: type: string enum: [literal, regex] description: > literal: exact substring match (case-sensitive by default). regex: Python re.MULTILINE pattern. Must compile at lint startup.

pattern: type: string minLength: 1 description: "The literal substring or regex source. Regex is validated at lint startup."

rationale: type: string minLength: 8 description: "Human-readable justification. Forces contributors to explain any new ban."

added_in: type: string description: "Release version when the term was introduced. Informational only."

case_sensitive: type: boolean default: true description: "If false, literal matches and regex patterns ignore case."

Initial payload example (ships in src/charter/neutrality/banned_terms.yaml):

#

schema_version: "1"

terms:

- id: PY-001

kind: literal

pattern: "pytest"

rationale: "Primary offender in pre-3.1.5 generic prompts; Python test framework name."

added_in: "3.2.0"

- id: PY-003

kind: regex

pattern: "\\bpip install\\b"

rationale: "Python package-install command; leaks Python assumption into generic guidance."

added_in: "3.2.0"

charter-ownership-invariant-contract.md

C-6 — Charter Ownership Invariant Contract

Kind: Test harness contract Covers: FR-001, FR-002, SC-001

Statement

A pytest module at tests/charter/test_charter_ownership_invariant.py MUST enforce that for each canonical function in the registry, there is exactly one FunctionDef AST node across all Python files under src/, located in the canonical file.

Initial registry

CANONICAL_OWNERS: dict[str, str] = {
    "build_charter_context":       "src/charter/context.py",
    "ensure_charter_bundle_fresh": "src/charter/sync.py",
}

Scan rules

  • Walk src/*/.py (exclude __pycache__, tests/, .worktrees/).
  • For each file, parse via ast.parse(file.read_text(), filename=str(file)).
  • Count top-level and nested FunctionDef / AsyncFunctionDef nodes whose node.name is in the registry.
  • The invariant is satisfied when, for each registry key:
  • Exactly one file contains a matching FunctionDef.
  • That file's repo-relative path equals the registry value.

Failure output contract

On violation, the test MUST name:

  • The function that failed the invariant.
  • Every file that contains a definition of that function (not just the extras).
  • The expected canonical location.

Example:

Charter ownership invariant violated for 'build_charter_context':
  canonical location: src/charter/context.py
  definitions found in:
    src/charter/context.py            (canonical)
    src/legacy/charter_helper.py:42  (DUPLICATE — remove or rename)

Machine-enforced assertion

# tests/charter/test_charter_ownership_invariant.py (skeleton)
import ast
from pathlib import Path

CANONICAL_OWNERS = {
    "build_charter_context": "src/charter/context.py",
    "ensure_charter_bundle_fresh": "src/charter/sync.py",
}

def _find_defs(repo_root: Path, name: str) -> list[Path]:
    hits = []
    for py in repo_root.joinpath("src").rglob("*.py"):
        if any(part in {"__pycache__"} for part in py.parts):
            continue
        try:
            tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
                hits.append(py.relative_to(repo_root))
    return hits

def test_charter_ownership_invariant(repo_root):
    violations = []
    for fn_name, canonical in CANONICAL_OWNERS.items():
        found = _find_defs(repo_root, fn_name)
        if len(found) != 1 or str(found[0]) != canonical:
            violations.append(f"{fn_name}: expected single def at {canonical}, found {found}")
    assert not violations, "\n".join(violations)

Non-contract

  • Methods with the same name on classes (e.g., Foo.build_charter_context) are NOT counted — the invariant is module-level free functions only.
  • Adding new names to CANONICAL_OWNERS is a per-mission decision; this contract establishes the mechanism, not a fixed registry.

Breakage response

A failing invariant test means the mission's hard success criterion (SC-001) has regressed. Fix is always to consolidate back to the canonical location, never to add an exception to the test.

charter-public-import-surface.md

C-1 — Charter Public Import Surface (baseline preservation)

Kind: Import API contract Covers: FR-007, NFR-005, SC-002

Statement

For every symbol reachable through from specify_cli.charter import X or from specify_cli.charter.submodule import Y at the pre-mission baseline (v3.1.5 / v3.1.6), the following MUST remain true after this mission merges:

1. The same import resolves successfully (possibly with a DeprecationWarning). 2. The resolved object is the same object reachable via the canonical from charter.… import … path, i.e., specify_cli.charter.X is charter.X for re-exports, or the two import forms return callables that satisfy the same behavioral tests.

Machine-enforced assertion

A test under tests/specify_cli/charter/test_import_surface_preservation.py iterates a frozen baseline list of known public import paths and verifies each resolves. The baseline is computed once during implementation by inspecting src/specify_cli/charter/__init__.py and the three sys.modules-aliased submodules (compiler, interview, resolver).

LEGACY_IMPORTS: list[tuple[str, str]] = [
    # (legacy_path, canonical_path)
    ("specify_cli.charter", "charter"),
    ("specify_cli.charter.compiler", "charter.compiler"),
    ("specify_cli.charter.interview", "charter.interview"),
    ("specify_cli.charter.resolver", "charter.resolver"),
    # + each public symbol re-exported from __init__.py, frozen at mission time
]

for legacy, canonical in LEGACY_IMPORTS:
    with pytest.warns(DeprecationWarning, match="specify_cli.charter"):
        legacy_obj = importlib.import_module(legacy)
    canonical_obj = importlib.import_module(canonical)
    assert legacy_obj is canonical_obj

Non-contract

  • New symbols added to src/charter/* after this mission are NOT required to be re-exported through the legacy surface.
  • Internal (underscore-prefixed) names under src/charter/ are NOT part of the contract.

Breakage response

Any test failure in test_import_surface_preservation.py is a P0 for the mission — it means a known legacy caller will break without warning. Remediation is to restore the re-export (add it to the shim __init__.py or confirm the sys.modules alias); never remove the failing line from the baseline list.

language-scoped-allowlist-schema.yaml

C-5 — Language-Scoped Allowlist YAML v1 Schema

#

Covers: FR-009, FR-013

$schema: "https://json-schema.org/draft/2020-12/schema" $id: "contracts:language-scoped-allowlist.v1" title: "Language-Scoped Allowlist v1" description: > Declares repo-relative paths (or glob patterns) that are intentionally language-scoped and therefore exempt from the generic neutrality lint. Every entry must justify itself so future readers can audit the scope.

type: object required: [schema_version, paths] additionalProperties: false

properties: schema_version: type: string const: "1"

paths: type: array description: "Allowlist entries. Empty array is legal (means no language-scoped artifacts exist yet)." items: type: object required: [path, scope, owner, reason] additionalProperties: false

properties: path: type: string minLength: 1 description: > Repo-relative path with forward slashes. Glob patterns supported via pathlib.Path.glob (e.g., "src/charter/profiles/python/*/.md"). MUST resolve to at least one existing file at lint time; stale entries fail the lint with STALE ALLOWLIST ENTRIES.

scope: type: string pattern: "^[a-z][a-z0-9_-]*$" description: "Language family identifier. Convention: lowercase. Examples: python, node, ruby, go, rust."

owner: type: string minLength: 2 description: "Team or role responsible for keeping this file's scope accurate."

reason: type: string minLength: 8 description: "Why this file is intentionally language-scoped."

added_in: type: string description: "Release when the path was allowlisted. Informational."

Initial payload (ships empty in this mission):

#

schema_version: "1"

paths: []

#

Example entry, for documentation:

#

- path: "src/charter/profiles/python/README.md"

scope: python

owner: "charter team"

reason: "Canonical Python profile guidance; mentions pytest intentionally."

added_in: "3.2.0"

neutrality-lint-contract.md

C-3 — Neutrality Lint Pytest Contract

Kind: Test harness contract Covers: FR-010, FR-011, SC-003, SC-005, NFR-001

Statement

A single pytest module at tests/charter/test_neutrality_lint.py MUST implement the neutrality-lint regression gate with the following behavior:

Discovery

  • The test is collected automatically under pytest tests/ (default pytest configuration; no opt-in marker required).
  • The test produces a single top-level test case (test_generic_artifacts_are_neutral) PLUS per-term parametrized assertions for diagnostic granularity.
  • Total runtime on a baseline developer machine MUST NOT exceed 5.0 seconds wall-clock (NFR-001).

Inputs

  • src/charter/neutrality/banned_terms.yaml — v1 schema per C-4.
  • src/charter/neutrality/language_scoped_allowlist.yaml — v1 schema per C-5.
  • All files under the following scan roots (initial configuration; extensible via lint config):
  • src/doctrine/primary bias surface; shipped doctrine artifacts (agent profiles, styleguides, toolguides) are where Python/pytest leakage has historically occurred. Scanning this root is load-bearing for FR-008.
  • src/charter/ (excluding src/charter/neutrality/ itself to avoid self-match)
  • src/specify_cli/missions/*/command-templates/
  • src/specify_cli/missions/*/mission.yaml
  • .kittify/charter/ (if present in the working tree)

Scanning src/doctrine/ is not optional: the Mission #653 motivation was Python-tool bias in shipped doctrine, so a lint that omits it would pass while the real surface remained contaminated.

Behavior

  • For each scanned file whose repo-relative path does NOT match any LanguageScopedPath entry (literal or glob), the scanner greps for each BannedTerm pattern.
  • Any hit produces a BannedTermHit(file, line, column, term_id, match).
  • A stale allowlist entry (path that resolves to zero files) produces a stale_allowlist_entries entry.
  • The test passes iff NeutralityLintResult.passed is True.

Failure output contract

On failure, the test MUST produce a diagnostic of this shape (pytest's standard assert inspection is sufficient if the message is pre-formatted):

Neutrality lint failed.

HITS:
  src/charter/example.md:14:3 — term_id=PY-001 matched="pytest"
  src/specify_cli/missions/software-dev/command-templates/plan.md:42:7 — term_id=PY-003 matched="pip install"

STALE ALLOWLIST ENTRIES:
  src/charter/profiles/python/missing.md  (no file resolves this path)

Remediation for each HIT:
  (a) Remove the banned term from the file, OR
  (b) Add the file's path to src/charter/neutrality/language_scoped_allowlist.yaml
      if the file is INTENTIONALLY language-scoped.

Remediation for STALE entries:
  Delete the stale path from language_scoped_allowlist.yaml, or restore the expected file.

Fault-injection test (SC-005)

A separate test case temporarily writes a synthetic generic-scoped file containing pytest into a tmp path, points the lint at the tmp scan root, and asserts the lint fails with a hit on that file. Restores on teardown.

Machine-enforced assertion

# tests/charter/test_neutrality_lint.py (skeleton)
def test_generic_artifacts_are_neutral():
    result = run_neutrality_lint()
    assert result.passed, _format_failure(result)

def test_fault_injection_catches_regression(tmp_path):
    (tmp_path / "generic.md").write_text("run pytest to verify\n")
    result = run_neutrality_lint(scan_roots=[tmp_path])
    assert not result.passed
    assert any(hit.term_id == "PY-001" for hit in result.hits)

def test_runtime_budget():
    import time
    start = time.perf_counter()
    run_neutrality_lint()
    assert (time.perf_counter() - start) < 5.0

Non-contract

  • The test does NOT examine file content for semantic meaning (no AST, no NLP). Only pattern matching.
  • The test does NOT gate PR merge directly — CI enforces via the existing pytest job.

Breakage response

Lint failures are expected and intentional when a contributor introduces a banned term. The remediation instructions in the failure output are the canonical response. Do not weaken the lint or add terms to the allowlist without explicit review.

shim-deprecation-contract.md

C-2 — Shim Deprecation Warning Contract

Kind: Runtime warning contract Covers: FR-005, NFR-004, SC-006

Statement

Importing any module under the legacy specify_cli.charter.* path MUST raise a single DeprecationWarning with the properties below. The warning is emitted from the package __init__.py only; submodule shims (compiler.py, interview.py, resolver.py) stay silent because Python evaluates the parent package's __init__.py on the way to resolving any submodule, so a single warning site is sufficient — and emitting per-submodule would produce a cascade of duplicate warnings for the common from specify_cli.charter.compiler import X pattern.

Warning properties

1. category is exactly DeprecationWarning (not FutureWarning, UserWarning, PendingDeprecationWarning, etc.). 2. message (string form) contains all three of:

3. The warning is raised once per Python process under the default warning filter — repeated imports in the same process do not re-warn, because the package __init__.py body executes only on first import. 4. stacklevel=2 — the warning's reported location points at the caller (the file doing the import), not at specify_cli/charter/__init__.py itself. 5. Module-level metadata attributes on the package (__deprecated__, __canonical_import__, __removal_release__, __deprecation_message__) are present on specify_cli.charter and match the warning text (cross-validated).

  • The canonical replacement package name (charter).
  • The legacy path being deprecated (specify_cli.charter).
  • The target removal release (e.g., 3.3.0).

Why package-only, not per-submodule

If every submodule shim also called warnings.warn, the common import idiom

from specify_cli.charter.compiler import X

would trigger two warnings (one when Python evaluates specify_cli/charter/__init__.py during package initialization, one when the submodule shim body runs). That would be:

  • noisy for callers who can only act on one signal,
  • test-hostile (the test cannot meaningfully assert "exactly N warnings" across all N import shapes),
  • and redundant (the package-level warning already names every submodule's canonical replacement).

The contract is therefore: the package speaks; submodules remain silent.

Machine-enforced assertion

Test under tests/specify_cli/charter/test_shim_deprecation.py:

import importlib, sys, warnings
import pytest

LEGACY_IMPORT_SHAPES = [
    "specify_cli.charter",
    "specify_cli.charter.compiler",
    "specify_cli.charter.interview",
    "specify_cli.charter.resolver",
]

def _reset_modules():
    for m in list(sys.modules):
        if m.startswith("specify_cli.charter") or m == "charter" or m.startswith("charter."):
            sys.modules.pop(m, None)

@pytest.mark.parametrize("module_path", LEGACY_IMPORT_SHAPES)
def test_legacy_import_emits_deprecation_warning(module_path):
    _reset_modules()
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always", DeprecationWarning)
        importlib.import_module(module_path)
    depr = [w for w in caught if issubclass(w.category, DeprecationWarning)]
    assert len(depr) >= 1, (
        f"Importing {module_path} produced zero DeprecationWarnings; "
        f"expected at least one from the specify_cli.charter package __init__."
    )
    # The package-level warning is the one we mandate. Other libraries may emit
    # their own unrelated DeprecationWarnings during import; we only require that
    # ours fires, not that it is the only one.
    ours = [w for w in depr if "specify_cli.charter" in str(w.message)]
    assert ours, f"No DeprecationWarning mentioning 'specify_cli.charter' was emitted."
    assert len(ours) == 1, (
        f"Expected exactly one specify_cli.charter DeprecationWarning across "
        f"all import shapes; got {len(ours)}. Submodule shims must not re-warn."
    )
    msg = str(ours[0].message)
    assert "charter" in msg
    assert "specify_cli.charter" in msg
    assert "3.3.0" in msg                                   # removal release

def test_package_carries_deprecation_metadata():
    _reset_modules()
    pkg = importlib.import_module("specify_cli.charter")
    assert getattr(pkg, "__deprecated__", False) is True
    assert pkg.__canonical_import__ == "charter"
    assert pkg.__removal_release__ == "3.3.0"
    assert "specify_cli.charter" in pkg.__deprecation_message__
    assert pkg.__removal_release__ in pkg.__deprecation_message__

Non-contract

  • External callers that explicitly suppress DeprecationWarning via warnings.filterwarnings("ignore", category=DeprecationWarning) before import will not see the warning — that is standard Python behavior and not a failure of this contract.
  • Submodule shims (compiler.py, interview.py, resolver.py) are permitted to carry __deprecated__ / __canonical_import__ attributes for documentation, but they MUST NOT call warnings.warn themselves.
  • The content of __canonical_import__ / __removal_release__ is authored on the package __init__.py in this mission; future missions may update the removal release across the package atomically.

Breakage response

If the package fails to emit the warning (forgotten during a refactor, filter re-enabled, stacklevel broken), the parametrized test fails with a message naming the offending import shape. Fix is always in src/specify_cli/charter/__init__.py, never in the test. If a submodule shim starts emitting its own DeprecationWarning (reintroducing the duplicate-warning bug), the len(ours) == 1 assertion will catch it.