Contracts

canonical-home-owner.md

Contract — the canonical SPEC_KITTY_HOME owner fixture in tests/conftest.py

Status: binding on WP-a (sole author of the fixture), and on WP-b / WP-c as consumers of the name it fixes.

Why this document exists, stated before anything it fixes. The first planning pass assigned both owner probes to WP-b while WP-a added the fixture they request. Files were disjoint; contracts were not, and that absent contract is why the return None / tuple[str, str] conflict survived planning into FR-005 and SC-005. Naming the fixture is not a local detail: FR-010's resolver limb treats a parameter naming this owner as tmp_path for both the silhouette and value resolution, so the name is a SCANNER INPUT. This document is therefore authored and committed before the fixture, and WP-a/T012 parses it for the declared name — the binding is mechanical, not adjacency.


1. The name

Fixture namecanonical_home
Declaring moduletests/conftest.py

canonical_home is not a free choice. tests/architectural/_home_pin_scan.py (OWNER_PARAM_NAMES) ships frozenset({"tmp_path", "canonical_home", "runtime_home"}), and its own docstring records that entry as provisional and unbound: the name was taken from FR-010's prose at a moment when no contract named the owner, and "if WP03 names the owner anything else this entry silently stays inert and only the shipped tests — which supply the name on both sides of the equality — stay green." Measured in WP01's review: removing or renaming canonical_home reds only WP01's own fixture tree, and runtime_home can be deleted outright with the suite still green.

Therefore, normatively: the declared name in the table above MUST be a member of _home_pin_scan.OWNER_PARAM_NAMES, and WP-a/T012 asserts exactly that, parsing the name out of this file. Without that assertion FR-010's limb goes permanently inert with every WP01 assertion still green — which is the failure FR-010 exists to prevent ("the guard goes blind in proportion to R1b's adoption").

2. Stance

PropertyValueHow it is fixed
autouseFalseBy absence of the keyword. C-014 limb (ii) — a module that does not name the owner never instantiates it — is a property of pytest's fixture machinery and is only available while the fixture is non-autouse.
ScopefunctionBy ABSENCE of a scope= kwarg, never by scope="function". An explicit scope= on a pin-bearing fixture is registered inert as SCOPE-EXPLICIT in _home_pin_scan.INERT_LIMBS; writing it here would falsify a population-0 assertion in the module that ships it.
Return valueNoneThe body contains no return and no yield. AST-asserted.
Placementstrictly after tests/conftest.py:298Line 298 is the return home_base of _isolated_worker_home. git diff -U0 must show no hunk touching any line at or below 298.

2a. Why None, and why it must not be "improved"

FR-005 states it and the reason is load-bearing for SC-012's non-circularity. A fixture that returns its own path invites

assert os.environ["SPEC_KITTY_HOME"] == canonical_home   # WRONG

which compares the environment against the fixture's own report and passes for an owner whose body is return None with no setenv and no mkdir. Returning None forces the probe to compute str(tmp_path / "home") itself, from its own tmp_path. A future pass that "helpfully" returns the path re-opens exactly this hole, so the property is stated here rather than left to be inferred from the body.

2b. tuple[str, str] belongs to E, not to the fixture

FR-005 and SC-005 both carried the conflation. Stated once, normatively: tuple[str, str] is the type of the trailing composite_key component of the owner's ENTRY IN E (Exempt.key, whose full type is MemberKey = tuple[str, str, str]), and is NEVER the type of the fixture. An implementer told to AST-assert both finds that whichever limb they pick the other is unsatisfiable, and the cheapest escape is to weaken one — C-002's named failure mode.

3. Body

Normative requirements on the body, in full:

1. It establishes the home ONLY via monkeypatch.setenv, with the literal key "SPEC_KITTY_HOME" and a value resolving to str(tmp_path / "home"). 2. It contains no monkeypatch.setattr(Path, "home", …) and no process-global patch of any kind (no os.environ[...] = , no setattr on any imported module, no setdefault). 3. It creates the directory before the test body runs — a mkdir in the fixture body, so Path(os.environ["SPEC_KITTY_HOME"]).is_dir() holds at test-body entry. 4. It never overrides a definition keeping its own pin.

Requirements 2 and 4 are FR-006, and requirement 2 is only its static half. FR-006 requires the rule DEMONSTRATED BEHAVIOURALLY, not merely asserted from source: T012 carries the establishment half (SC-012 limb 1) and T013 the never-wins half (SC-012 limb 2, with its discriminating negative control).

3a. Precedence relative to _isolated_worker_home

tests/conftest.py:272-286 records a decision this fixture must not violate: the worker home is established only via the HOME/USERPROFILE/XDG env vars, deliberately not by monkeypatch.setattr(Path, "home", …), because the setattr form pinned Path.home() regardless of any later in-test setenv("HOME", …) and silently won over ~16 tests/sync cases. C-005 binds that decision unchanged.

The canonical owner adopts the same stance for the same reason, one variable over:

_isolated_worker_homecanonical_home
VariablesHOME / USERPROFILE / XDGSPEC_KITTY_HOME
autouseTrueFalse
Scopefunctionfunction
Establishmentmonkeypatch.setenv onlymonkeypatch.setenv only

Ordering: _isolated_worker_home is autouse and function-scoped, so pytest instantiates it before canonical_home for any test that requests the owner. The two write disjoint variables, so the ordering is not a precedence question between them. The precedence question this Mission is actually about is owner versus a requesting module's own pin, and it is settled the way :272-286 settles it: a later monkeypatch.setenv in a fixture that requests the owner runs after the owner's setup and therefore wins. T013's probe (a) must place its pin in a module-local FIXTURE requesting the owner, never in the test body — fixture setup completes before the body runs, so a body-level setenv wins by ordering unconditionally and proves nothing about the fixture-versus-fixture precedence this clause is about.

4. Placement, and what the diff-shaped check cannot see

The addition is strictly after line 298. That criterion alone is satisfiable with its risk untouched: *inserting the owner above line 253 shifts _isolated_worker_home down and changes conftest definition ordering while the old line range shows no modification.*

The only limb that sees that is SC-010/NFR-005, and its form is fixed here:

> Assert the module's ORDERED LIST of definition names, with the newly-added owner removed, is > equal to the same list at the merge base. One known addition, then exact equality, over > the whole module — ordering on both sides of the anchor.

Explicitly not:

Mission governed by C-002 it was the last surviving scalar comparand;

to this very file. (SC-010 records both ends of that axis: the criterion was first written so it could not fail, then repaired so it could not pass.)

below the anchor, and conftest fixture resolution depends on the ordering throughout.

  • a scalar definition index — invariant under insert-one-above-plus-delete-one-above, and in a
  • "unchanged" outright — literally unsatisfiable once FR-005 requires this WP to add the owner
  • narrowed to the names preceding _isolated_worker_home — that form cannot see a reorder

5. Consumers, and the assertions bound to this file

SubtaskBinding
T011The fixture carries the name in §1 and every property in §2 and §3.
T012Parses this file for the declared name, autouse stance and scope; asserts the tests/conftest.py fixture carries that name and those properties; and asserts the same parsed name is a member of _home_pin_scan.OWNER_PARAM_NAMES (§1).
T013Probe (a) — the E-slot member — pins str(tmp_path / "home") in a module-local fixture requesting the owner (§3a). Probe (b) — the non-member negative control — pins str(tmp_path / "probe-home"), a value the owner can never produce, and is the only assertion of the pair that can be falsified.
T014The owner's entry in E is keyed on tests/conftest.py at the owner's setenv site; E's keys are content-addressed 3-tuples and any lineno used to recompute one comes from discover() at runtime, never from a literal.

6. What this document cannot see

Whether the owner works. Nothing about a contract can. SC-011 is pure shape, and C-014 limbs (i) and (ii) are properties of pytest's fixture machinery rather than of the owner's body — an owner whose body is return None with no setenv and no mkdir satisfies both. SC-012 limb 1 carries the entire behavioural load alone, and SC-011 green is not evidence the owner works.

home-pin-scan-seam.md

Contract — tests/architectural/_home_pin_scan.py, the shared scanning seam

Status: binding on WP-0 (sole author), WP-b and WP-c (consumers).

This repository has the failure this contract prevents on record as a live incident: tests/architectural/_sole_door_scan.py:13-27 documents Gates 4 and 5 each rolling an independent, drifting copy of the same primitives, Gate 4's copy having "already lost Gate 1's docstring rationale … a live drift, not a hypothetical one", and names the promoted shared module as the fix. R1a follows that precedent rather than re-earning it. Prose did not prevent it there; §3c below is why it will here.


Ownership

PackageMay do
WP-0Author the module end to end, including render_census / render_baseline and the __main__ regeneration entry.
WP-b / WP-cImport and invoke. NEVER edit the module. _home_pin_scan.py is in lane-a's write scope only; a downstream package editing it fails WP01's DoD and WP02's not_done_if. Guard-only helpers belong in the guard's own module, not here.

Putting the generators in WP-0 is a deliberate strengthening of the spec's "WP-b then extends": FR-004(2) already requires both artefacts to be emitted by this module, so WP-0 owning them removes the only reason WP-c would touch it — and removes a shared-file conflict from the parallel section of the plan.

Public surface

def enumerate_py_files(root: Path) -> list[Path]: ...
def byte_prefilter(paths: Iterable[Path]) -> list[Path]: ...
def parse_module(path: Path) -> ast.Module: ...
def resolve_value(node: ast.AST, bindings: Mapping[str, ast.AST]) -> str | None: ...
def find_write_sites(tree: ast.Module, *, key: str) -> list[WriteSite]: ...
def key_member(site: WriteSite, chain: Sequence[ast.AST]) -> Attribution | None: ...
def discover(root: Path, *, prefilter: bool = True) -> set[Member]: ...
def render_census(members: Iterable[Member], *, sha: str, owed_to: str) -> str: ...
def render_baseline(members: Iterable[Member], *, exempt: tuple[Exempt, Exempt]) -> str: ...
SymbolContract
enumerate_py_filesEvery .py under root. Never narrowed — not by directory, not by filename (C-003 states narrowing by directory or filename qualifies under neither carve-out form). SC-007 asserts this set, with the count reported and not asserted.
byte_prefilterFiles whose raw bytes contain b"SPEC_KITTY_HOME". Mandatory, not an optimisation (FR-002). Deliberately separate from the walk so the walk stays assertable while the parse set shrinks.
parse_modulePropagates SyntaxError. except SyntaxError: continue is forbidden — it narrows the walk with nothing firing and buys budget headroom, which is NFR-001's defeat wearing an exception handler (SC-013).
resolve_valueSingle-assignment local bindings. Eight value forms, not four: unwraps str / Path / os.fspath / joinpath, joins f-strings, and — added by FR-001's widening — resolves os.path.join, %-format, .format() and + concatenation. Each widened form is a sub-form carrying its own positive control (FR-007), and all four are registered inert at both keys (SKH-VAL-, HOME-VAL-, population 0 each). Returns None when unresolvable, and None never matches anything.
find_write_sitesParameterised by environment variable — called with key="SPEC_KITTY_HOME" for membership and key="HOME" for home_partition (FR-003). One implementation, two keys; never a second finder. The three-form write, receiver-agnostic, tested on the call: setenv as attribute or bare name with a literal "SPEC_KITTY_HOME" first argument; os.environ["SPEC_KITTY_HOME"] = / environ[...] = ; .setdefault("SPEC_KITTY_HOME", …). Receivers bound by a with … as item must bind — not only ast.Assign — for correctness. That binding is NOT what admits :1165, and the claim that it is was struck as FALSE (§0.8): measured by removing withitem binding entirely, all 40 members are still found and :1165 is still admitted. Receiver-agnosticism on the call is what admits it; a receiver-qualified matcher is what would drop it. withitem-bound-receiver resolution fires only when a value expression references a with-bound name — population 0, registered inert as WITHITEM-VALUE-REF. setdefault has population 0 and its limb is asserted-inert (FR-007).
key_memberReturns an Attribution, NOT a Member — the two jobs are done by different functions. *(Corrected from WP01: the row previously gave `-> Member
discoverThe whole pass, resolving both variables and attaching home_partition. Pre-filter soundness for the second variable, stated not assumed: a member's scope chain lies within one file, so every HOME write that can change its partition sits in the same file as that member's SPEC_KITTY_HOME write — a byte-hit file by construction; widening the pre-filter is not permitted as a substitute for this argument. Measured, with the denominator published: of the 50 files holding a HOME write, 21 are invisible to the pre-filter (33 sites), none of them holds a member, and no member file is non-hit; the truly unfiltered HOME pass (85 sites / 50 files) and the pre-filtered pass (52 / 29) give identical partitions for all 40 members. The HOME limb ships a positive control (FR-007/B6), and it is a limb, not an inert sub-form — 13 of the 40 members re-pin HOME. Root-parameterised (FR-009) so every guard behaviour is exercisable against a synthetic tree without editing a real test module, and prefilter-parameterised so OD-002 form (a) is one classifier called twice rather than two implementations agreeing with each other.
render_census / render_baselineThe generators. Both outputs carry a header stating they are generated and naming the single regeneration command. render_baseline hashes the sorted composite_key set, never the file bytes.

Anti-drift mechanism (the teeth)

An architectural test asserts, by AST, that no module under tests/ other than _home_pin_scan.py contains a second implementation of the predicate. Concretely: test_spec_kitty_home_pin_guard.py, test_spec_kitty_home_pin_prefilter.py and test_spec_kitty_home_pin_census.py contain

  • zero ast.parse calls,
  • zero ast.NodeVisitor subclasses,

and obtain everything through from tests.architectural._home_pin_scan import ….

This is AST rather than text (C-003), it is cheap, and it converts the §0.8 instruction from an intention into a red. The seam is not the import; it is the test that makes the import the only option.

And the ban reaches the test modules themselves, which is why synthetic sources go through the seam. Every module importing _home_pin_scan is subject to the ban — including the positive-control modules, whose controls must parse a synthetic source. They parse it with _home_pin_scan.parse_module only; a control calling ast.parse directly would red the very guard it exists to serve, and the cheapest repair for that red is to exempt the control, which reopens the seam. Stated here because three separate subtasks ship such controls and each would otherwise resolve it locally.

WP-0 is its own first consumer

The gate obtains R by extracting each window SHA's tests/ with git archive into a temporary directory and calling discover(root=<extracted>/tests). FR-009's root parameter is therefore exercised by WP-0 before WP-b depends on it, and the two-SHA measurement is evidence the seam works rather than a claim that it will.

The C-011 evidence artefact is explicitly NOT part of this seam

The independent reproduction at kitty-specs/isolated-home-pin-guard-r1a-01KZNMA3/research/spec_kitty_home_pin_evidence/ is checked in verbatim and must never be imported by, merged into, or "tidied" against this module. It is evidence precisely because the party it checks did not write it. discover() is compared against it; it is never derived from this module.

Key type — settled

MemberKey = tuple[str, str, str](rel_path, enclosing_qualname, normalized_token_line) — formed at the WRITE SITE, at the boundary, from the Member record, and from the already-parsed tree discover() holds — never by re-reading the file. anchoring.py:192-195 swallows SyntaxError and returns "<module>", so re-reading would silently degrade a broken file instead of raising and would leave SC-013's guarantee holding only on the parse_module path. composite_key_from_file is used only for the independent live recomputation, where the file is known-parseable because discover() just parsed it; not re-reading also saves a measured 0.19 s per pass. Composition: MemberKey = (relpath_posix, *composite_key_from_file(path, lineno)) — the primitive returns a 2-tuple, so a direct comparison is a mypy --strict type error. Never inside find_write_sites or key_member. The bare 2-tuple yields 19 distinct values over the 40 members and is refused; all three authorities C-012 names already use the 3-tuple for row identity. Member also carries lineno, which is explicitly non-authoritative and never part of the key.

discover() asserts exactly-one at import over its own output, at MEMBER level: if two members produce the same MemberKey, red at import rather than silently deduplicate. The 3-tuple is non-injective over the 191 walked sites (190 distinct, one class of two) but the member-level collision population is 0 — the pair's values are tmp_path/"one" and tmp_path/"two", so neither is a member. Hazard real, population 0, one string literal away.

Do NOT implement this with assert_descriptor_unique_within_qualname per member. Measured under the D-1 rule (occurrence=None), it raises on 11 of the 40: code_tokens_by_line strips string literals, so _isolated_home's three consecutive setenv calls in tests/cli/commands/test_sync_commands.py (SPEC_KITTY_HOME, HOME, LOCALAPPDATA) share one normalized token line while only one is a member. Source-scoped descriptor uniqueness fires on sites the guard does not own. ContentDescriptor is retained as the diagnostic vehicle for reporting a collision, never as the uniqueness predicate.

The gate driver is NOT in this module

The window measurement — git-archive extraction at two SHAs, the rename detector, ±1 stability, banding, the widening schedule, verdict emission — lives in tests/architectural/_home_pin_gate.py and imports discover. This module holds no subprocess and no git: collected tests import it under a 6-second budget on every PR. The gate is a consumer, and it owns no predicate, so the no-second-copy property is preserved.