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
| # | Contract | Kind | File |
|---|---|---|---|
| C-1 | Charter public import surface — baseline preservation | Import API | charter-public-import-surface.md |
| C-2 | Shim deprecation warning — emission contract | Runtime | shim-deprecation-contract.md |
| C-3 | Neutrality lint pytest — discovery & failure-output contract | Test harness | neutrality-lint-contract.md |
| C-4 | Banned-terms YAML — v1 schema | Config file | banned-terms-schema.yaml |
| C-5 | Language-scoped allowlist YAML — v1 schema | Config file | language-scoped-allowlist-schema.yaml |
| C-6 | Charter ownership invariant — enforced at CI | Test harness | charter-ownership-invariant-contract.md |
How these map to spec requirements
| Contract | Covers |
|---|---|
| C-1 | FR-007 (CLI behavioral invariance via stable import API), NFR-005 (no startup regression) |
| C-2 | FR-005 (DeprecationWarning catchable), NFR-004 (standard warnings category) |
| C-3 | FR-010, FR-011 (regression test mechanics + error messages) |
| C-4 | FR-008, FR-014 (banned-term enforcement, single-file maintenance) |
| C-5 | FR-009, FR-013 (allowlist existence, Python guidance scope) |
| C-6 | FR-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/AsyncFunctionDefnodes whosenode.nameis 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_OWNERSis 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/(excludingsrc/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
LanguageScopedPathentry (literal or glob), the scanner greps for eachBannedTermpattern. - 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_entriesentry. - The test passes iff
NeutralityLintResult.passedis 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
DeprecationWarningviawarnings.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 callwarnings.warnthemselves. - The content of
__canonical_import__/__removal_release__is authored on the package__init__.pyin 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.