Verification evidence — egress-refusal-consolidation-3110-01KYW895
This page is the mission's verification record: what was measured, on what tree, with what command, and — the part that makes it usable — what was measured wrongly and re-taken, and which greens are conditional on work that has not landed.
How to read it
Three reading rules, each of which this mission learned the hard way.
- A number without its subject is not a measurement. Every count below names the tree
(
HEAD), the interpreter, and the command. Where a number came from someone else's run rather than the run recorded here, it is attributed. - A green without its limit is a claim, not evidence. The conditionality ledger says which greens a successor must re-take and which were never conditional.
- Per clause, never as one verdict. Four criteria in this mission are satisfiable by the work not having been done. They are reported clause by clause in Per-clause criterion reporting, and each says which clause discriminates.
The tree everything was measured on
Asserted in process before each measurement, not assumed — the mission's own method note records three invalid verifications whose common shape was an exit status or a path that was not the one the measurer believed.
| Repository root | /home/jeroennouws/dev/sk-missions/3110 |
| Branch | pr/egress-refusal-consolidation-3110 |
HEAD |
673a718f0c155310838eb2aaa0ebcf768b40c3ee |
Consolidation baseline (HEAD of the pre-mission tree) |
985ae6484b20f2c935fdad858bc1f868c73835e1 |
specify_cli resolved to |
/home/jeroennouws/dev/sk-missions/3110/src/specify_cli/__init__.py |
import specify_cli was asserted to resolve inside this clone before every run. A bare
python3 -c "import specify_cli" on this machine resolves to a different, concurrently-edited
checkout; every non-pytest invocation below sets
PYTHONPATH=/home/jeroennouws/dev/sk-missions/3110/src explicitly.
Ancestry is not the test of whether a lane landed. Lane branches were squash-merged, so no
lane commit is an ancestor of HEAD and git merge-base reports nothing useful. Consolidation
was verified by content: src/specify_cli/egress.py and
src/specify_cli/decisions/ownership.py are present, and both */egress_consent.py modules are
gone — confirmed by import, not by git log:
saas_client/egress_consent.py importable: False
tracker/egress_consent.py importable: False
T037 — NFR-006 / SC-014 [one-off]: the Python 3.11 run
Why it is required in executed form. Only 3.14 is installed on this machine; CI runs
3.11/3.12. ownership.py's unreadable-ledger branch sits on a real Path.exists()/is_dir()
EACCES divergence between those interpreters, so a judgement about version-divergent branches
made on 3.14 is not verification of anything.
Environment, built for this measurement:
uv venv --python 3.11 <scratch>/venv311
uv pip install --python <scratch>/venv311/bin/python -e ".[test]"
| 3.11 venv | system interpreter | |
|---|---|---|
| Python | 3.11.15 |
3.14.4 |
pytest |
9.0.3 (venv site-packages) |
9.1.1 (user site, ~/.local/lib/python3.14/) |
The 3.11 venv is the more faithful of the two in a second respect nobody asked for:
pyproject.toml pins pytest>=9.0.3,<9.1 and says why (the 9.1.x reorder_items
INTERNALERROR, #2884). The venv resolved to 9.0.3, matching CI; the system interpreter's
user-site pytest is 9.1.1, outside the pin.
What was run: this mission's nine touched test files — which include both attribution
guards (tests/specify_cli/saas_client/test_client_consent_gate_3030.py and
tests/sync/tracker/test_saas_client_consent_gate_3030.py, the two modules that hold
_saas_site_attribution / _tracker_site_attribution) — each as its own single-file pytest
process from the clone root. Output written to a file per run and read from the file; no run was
piped, and the N passed line is the evidence rather than the exit code.
The N passed lines, verbatim, on 3.11.15 / pytest 9.0.3:
tests/specify_cli/saas_client/test_client_consent_gate_3030.py
============================= 21 passed in 55.37s ==============================
tests/sync/tracker/test_saas_client_consent_gate_3030.py
============================= 27 passed in 50.64s ==============================
tests/architectural/test_gate_coverage.py
======================== 37 passed in 490.14s (0:08:10) ========================
tests/architectural/test_integration_boundary.py
========================= 4 passed in 62.45s (0:01:02) =========================
tests/specify_cli/test_egress_consolidation_3110.py
============================= 13 passed in 52.76s ==============================
tests/specify_cli/decisions/test_ownership_3111.py
======================== 33 passed in 63.31s (0:01:03) =========================
tests/specify_cli/saas_client/test_decision_widen_ownership_3111.py
======================== 11 passed in 68.01s (0:01:08) =========================
tests/specify_cli/cli/commands/test_decision_widen_subcommand.py
======================== 28 passed in 64.97s (0:01:04) =========================
tests/invocation/test_adapters.py
======================== 18 passed in 63.38s (0:01:03) =========================
Input count stated alongside the greens, because "all passed" without it is unfalsifiable: 9 files, 192 collected items, 0 failures, 0 errors, 0 skips on 3.11. Per-file collected counts are 21 / 27 / 37 / 4 / 13 / 33 / 11 / 28 / 18.
Provenance of the first two rows. The first attempt at this sweep was killed at exactly
10m00s — the harness timeout, not a test timeout — part-way through test_gate_coverage.py. The
two rows above were produced by pytest processes that had already exited rc=0 with a complete
summary line before the kill; the interrupted file was re-run narrowed, not explained, and its
partial output (35 of 37 items, all dots) is quoted nowhere as evidence. Same tree, same venv,
both halves.
T030's test is named, because a green run over files that never touch the branch is a true statement about nothing
The test carrying NFR-006 is:
tests/specify_cli/decisions/test_ownership_3111.py::test_unreadable_decisions_directory_yields_not_established_with_the_flag
It is the directory shape — chmod 0o000 on the containing decisions/ directory, file left
readable — and that shape is the requirement, not a stylistic preference. stat(2) needs search
permission on the parent, not read permission on the file, so the file=0o000 shape returns an
identical result on 3.11 and 3.14; the honest reading of an identical result is "no
divergence, NFR-006 discharged", which would be a false negative in this mission's only
portability gate.
What it asserts is resolve_decision_ownership's outcome — owned is False,
has_unreadable_ledger is True, unreadable_ledgers == ("mission-locked",), and a refusal that
names the ledger — not Path.exists()'s return value. A Path.exists() characterization test
passes identically whether or not ownership.py carries its except OSError, i.e. it is green on
precisely the regression the branch exists to catch.
Its file=0o000 companion, test_unreadable_index_file_is_also_handled, is labelled in its own
docstring as covering read_text → PermissionError at store.py:66 and is explicitly not
offered as NFR-006 evidence.
It did not skip. The test guards itself with mode_bits_enforced(index_file) and skips with a
stated reason if the process can read through a 0o000 directory. Had it skipped, that would not
be a discharge of NFR-006 and this page would say so instead of quoting a N passed line. It ran.
The process euid was 1000, so mode bits are enforced here.
The divergence the 3.11 run actually found — and it points the other way
Do not read this as NFR-006 failing. NFR-006 asks that behaviour hold on the interpreters CI
runs, and it does: test_ownership_3111.py is 33 passed on 3.11. The run found something
NFR-006's text does not cover, in the opposite direction — a branch that works on 3.11/3.12
and silently does nothing on 3.13+.
Measured on the same tree, in two venvs with the same pytest 9.0.3, so the interpreter is
the only variable:
| File | 3.11.15 | 3.14.4 |
|---|---|---|
tests/specify_cli/decisions/test_ownership_3111.py |
33 passed in 63.31s |
2 failed, 31 passed in 55.31s |
The two failures:
FAILED test_unstattable_mission_candidate_is_flagged_not_reported_as_no_missions
FAILED test_unstattable_mission_candidate_does_not_veto_a_hit_elsewhere
assert () == ('m-link',)
Mechanism, probed directly with a control before anything was attributed
3.11.15 3.14.4
CONTROL readable vault is_dir() -> True is_dir() -> True
vault = 0o000 is_dir() RAISES is_dir() RETURNS False
PermissionError (EACCES swallowed)
ownership.py:387 reads if not resolved.is_dir() or not resolved.is_relative_to(specs_root): continue, and the LOW-8 recording — unreadable.append(candidate.name) — hangs off the
except OSError on the following line. On 3.11/3.12 is_dir() raises, the handler runs, and the
dropped candidate is recorded. On 3.13+ is_dir() returns False, control takes the bare
continue at :388, and the drop is never recorded.
This is the module's own documented lesson, unapplied at the one place the recording depends on
it. Its docstring removes Path.exists() three times because it is EACCES-divergent
(:50-56, :120, :322-323, :434) and :374-380 states the is_dir() divergence explicitly
— then reads 3.14's non-raise as "a clean refusal on 3.14". It is not a clean refusal. It is
LOW-8's original defect, verbatim.
The operator-visible consequence, measured on both interpreters
3.11 : … (no mission under <root>/kitty-specs could be searched at all). 1 mission ledger(s) or
mission directory(ies) could not be read or parsed and so could not answer: m-link.
To fix: this is not a missing checkout — `git pull` will not fix it. Inspect the named
path … restore read+execute access (e.g. `chmod u+rx`) …
3.14 : … (no missions were found under <root>/kitty-specs to search).
To fix: run `git pull` (or otherwise restore kitty-specs/) and retry …
On 3.13+ the operator is told "no missions were found … run git pull" for a permission
problem — the exact sentence, and the exact wrong remedy, that
test_unstattable_mission_candidate_is_flagged_not_reported_as_no_missions was written to make
impossible.
Severity, scope, and disposition
Fail-closed on both interpreters. owned is False and missions_searched == () on 3.11 and
3.14 alike; nothing is transmitted and no consent is laundered. This is a diagnosis defect —
the same wrong-operator-action class as FU-J, LOW-6, LOW-7 and LOW-1 — not a consent leak.
It is nonetheless in scope for the repo: pyproject.toml declares
requires-python = ">=3.11", so 3.13 and 3.14 are supported interpreters, and 3.14 is the only
one installed on this machine.
This work package did not fix it and did not absorb it. WP07 writes no source and no tests;
a red it repaired, worked around, or quietly dropped from T038's file list would be exactly the
"unstated set" R-22 names, landing in the one artifact whose whole purpose is to be trusted. The
owning package is WP04, which is approved, so the route is escalation to the operator
rather than a unilateral reopen. Evidence refs:
tests/specify_cli/decisions/test_ownership_3111.py— the two named failures,assert () == ('m-link',)src/specify_cli/decisions/ownership.py:387-403— the site- the
is_dir()probe table and the two refusal strings above, both reproducible on this tree
A candidate fix (not applied here): catch the containment/is_dir() drop and the OSError
drop as two recorded outcomes rather than one raise-dependent one — i.e. probe readability
explicitly, the way the module already does one level down for the ledger, instead of leaning on
whether is_dir() happens to raise on the running interpreter.
T038 — NFR-005 / SC-009: isolated single-file runs
File count: 9. Confirmed against the actual diff rather than transcribed from the WP prompt —
git diff --name-status 985ae6484 HEAD -- tests/ returns exactly these nine paths and no others.
"All isolated runs passed" over an unstated set is vacuous; an empty set satisfies the bare claim.
Each run is one pytest process, one file, invoked from this clone's root, output written to a
file and read from the file. No run below was piped.
The set, stated with its count — 9 files, and every one of them listed. Both interpreters were
run so the isolated greens are not silently interpreter-bound; both used pytest 9.0.3 from a
venv, so the interpreter is the only variable between the columns.
| # | File | From | Items | 3.11.15 | 3.14.4 |
|---|---|---|---|---|---|
| 1 | tests/specify_cli/saas_client/test_client_consent_gate_3030.py |
WP01, WP03 | 21 | 21 passed in 55.37s |
21 passed in 61.97s |
| 2 | tests/sync/tracker/test_saas_client_consent_gate_3030.py |
WP01, WP03 | 27 | 27 passed in 50.64s |
27 passed in 67.74s |
| 3 | tests/architectural/test_gate_coverage.py |
WP02 | 37 | 37 passed in 490.14s |
37 passed in 582.40s |
| 4 | tests/architectural/test_integration_boundary.py |
WP03 | 4 | 4 passed in 62.45s |
4 passed in 62.59s |
| 5 | tests/specify_cli/test_egress_consolidation_3110.py |
WP03 | 13 | 13 passed in 52.76s |
13 passed in 66.04s |
| 6 | tests/specify_cli/decisions/test_ownership_3111.py |
WP04 | 33 | 33 passed in 63.31s |
2 failed, 31 passed in 55.31s |
| 7 | tests/specify_cli/saas_client/test_decision_widen_ownership_3111.py |
WP04 | 11 | 11 passed in 68.01s |
11 passed in 59.87s |
| 8 | tests/specify_cli/cli/commands/test_decision_widen_subcommand.py |
WP04 (T027 b) — pre-existing, CHANGED | 28 | 28 passed in 64.97s |
28 passed in 49.31s |
| 9 | tests/invocation/test_adapters.py |
WP05 | 18 | 18 passed in 63.38s |
18 passed in 48.08s |
Totals. 9 files, 192 collected items on each interpreter. On 3.11: 192 passed, 0 failed,
0 errors. On 3.14: 190 passed, 2 failed, 0 errors — the two failures in one file. 0 skips
and 0 xfails on both, checked file by file rather than inferred from the totals, because a
silently-skipping 0o000 test is the vacuous case this mission's own criteria name and it would
be invisible in a passed count.
Row 6 is the only divergence, and it is not a flake. It is a defect in ownership.py that
appears only on 3.13+, with the tests correct on both sides. It is analysed, attributed and
escalated in its own section,
and it is not absorbed into this work package, not fixed here, and not dropped from this list.
Recording nine files and quoting eight would be exactly the unstated set NFR-005 exists to
prevent.
Row 3's earlier reds were an invalid measurement, not a red. Under the system interpreter
this file produced 1 failure and 8 setup errors; under a venv it is 37 passed on both
interpreters. The cause and its control are in measurements that were
wrong. No red was green-washed: the environment
was wrong, and the same file run correctly is green.
tests/specify_cli/cli/commands/test_decision_widen_subcommand.py is on the list and is the row
most likely to be dropped, so it is called out: it is a pre-existing green module that WP04
changed, not a new one. Its DECISION_ID fixture was 22 characters and contained an I, so it
failed the FR-005 ULID check, and its nine patch(… SaasClient.from_env …) live-path tests ran
against a tmp_path owning no ledger, so they hit the new ownership refusal. "Every test added
or changed" includes changed ones, and a file that appears in the diff only as fixture
corrections is exactly the file an isolated-run list forgets.
Why isolation is the compensation and not belt-and-braces. #3115 (shard-parallel test
isolation) is OPEN and its sync half is deferred to #3136 (FU-G), so a full-suite red on this
surface is not attributable. The isolated single-file runs are the only trustworthy greens this
mission can produce — which also means no full-suite green is claimed anywhere on this page.
Pre-existing reds were not folded in. In particular,
tests/specify_cli/invocation/test_propagator_consent_gate_3030.py run before
tests/specify_cli/saas_client/test_client_consent_gate_3030.py in one process fails with "no
hosted-sync consent resolver is registered" — a fixture-teardown ordering artefact, deterministic
in alphabetical order. On a consent mission that text reads exactly like the defect under repair.
It was neither chased nor green-washed, and it is not in this mission's scope.
Per-clause criterion reporting (NFR-004 / R13)
Four criteria here are compatible with the work not having been done. Reporting them as one verdict would hide that.
SC-004 — three clauses, one of which discriminates
| Clause | Label | Result | What it proves, and does not |
|---|---|---|---|
1 — the non-fragment portion of both rendered DENIED strings is byte-identical |
[ratchet] |
holds | Already true at bb2020fea; the two strings differed in exactly one word before consolidation. It does not distinguish a correct consolidation from an incorrect one — it must merely stay true. |
| 2 — each transport's fragment names exactly its own enumerated identifier set, and no foreign kind | [ratchet] |
holds | True of the unconsolidated state by construction: under the operator's Q2 decision both current DENIED strings survive verbatim, so the mission produced no wording. The only way it can red is if the Key-Entities check fails on the existing text — which would have red at bb2020fea too. |
3 — binding identity: both deciding modules' project_egress_refusal is specify_cli.egress.project_egress_refusal |
[standing] |
holds — and this is the clause that discriminates | It reds on the state nothing else in the spec detects: a partial consolidation where tracker/egress_consent.py or its SaaS twin survives as a re-export, so the deciding module's by-value binding still points at the old object. A surviving re-export renders the identical correct string, so text cannot separate the two cases. |
"SC-004 passes" is therefore not evidence the consolidation happened. Clause 3 is. Measured here independently, in process, from two separately imported names — never two imports of one path, because comparing an object to itself proves nothing:
specify_cli.saas_client.client.project_egress_refusal is specify_cli.egress.project_egress_refusal -> True
specify_cli.tracker.saas_client.project_egress_refusal is specify_cli.egress.project_egress_refusal -> True
CONTROL, a different callable, must be False -> False
The control is there because an identity assertion that cannot return False is one of the five
proofs-that-could-not-fail this mission produced. It returned False.
SC-005 / #3030 — the guards are non-vacuous, and this is WP01's measurement, not mine
Recorded here attributed, because this work package did not take it. WP01 measured the
per-class non-vacuity floors as named integers — SAAS_CONSTRUCTION_SITE_FLOOR = 4,
TRACKER_CONSTRUCTION_SITE_FLOOR = 3 — over 1197 files under src/, identical to the count
under the older src/specify_cli root (936 files), which is why widening the scan moved no count.
WP01 reports MUT-4/5/6 killed and, for the SC-005 demonstration, that removing one site of each
class reds with assert 3 >= 4 and assert 2 >= 3.
What this buys: the assert scanned the floors replace reds only when every site of a class
disappears. A named integer makes losing one site red. What it does not buy: coverage. #3030
itself was closed upstream by the parent mission (journal-project-consent-3030-01KYKWQS, PR #3098,
merged 2026-07-31); the verdict is verified-already-fixed, and what this mission
established is that the guard keeping it closed is real rather than decorative.
I re-ran both modules on both interpreters and they are green (see T037 and T038). I did not re-take the mutation runs or the site-removal demonstration.
SC-010 — the four could not be determined assertions
[ratchet]. Input count measured, not transcribed: four, at
test_client_consent_gate_3030.py:297 and :631 and
test_saas_client_consent_gate_3030.py:382 and :657 — two per transport. All four were
already passing at bb2020fea. A green SC-010 is not evidence of this mission's work; the
requirement is that they keep passing, and they do.
SC-021 — the two tracker behavioural tests
[ratchet]. Both were already passing before this mission. The requirement is that they keep
passing. They are collected by fast-tests-sync alone, which is why WP02 routed
src/specify_cli/tracker/** into the sync change-group — see CI routing.
NFR-004 — five per-branch pins, and all five already hold at bb2020fea
NFR-004 is a [ratchet]. The build work was pinning the branches; the pins do not
discriminate a correct implementation from an incorrect one. Reported per pin, measured in
process against src/specify_cli/egress.py at HEAD:
| # | Branch | Pin | Result |
|---|---|---|---|
| 1 | DENIED |
contains sync opt-in (a concrete next action, not a bare "denied") |
holds |
| 2 | import failure | carries the exception text ({exc} in _IMPORT_FAILURE_TEMPLATE) |
holds |
| 3 | NO_RESOLVER |
names the resolver | holds |
| 4 / 5 | UNDETERMINED vs UNANSWERABLE |
both contain could not be determined and remain distinguishable from each other (correction C-1) |
holds |
Pins 4/5 are the pair a weak assertion would wave through, so the distinguishability check was
controlled: comparing UNDETERMINED_PROJECT_REFUSAL to itself returns False, so the check can
fail. The two rendered strings:
UNDETERMINED : the project that owns this data could not be determined, so its consent to
hosted sync could not be resolved; refusing to transmit (an undetermined
project is never a consenting one)
UNANSWERABLE : consent for the project at {project_root} could not be determined (the consent
chain raised or answered with a non-bool); refusing to transmit, because
inability to determine consent is not consent
SC-019 / SC-020 — grep gates: presence measured, content read separately
Both are presence checks, and the spec's own honesty notes say so: a file containing only the three search terms passes. The presence result is reported as a presence result.
SC-019 grep -rn "resolve_egress_consent\|ConsentedBatch\|project_egress_refusal" docs/adr/3.x/ docs/context/
-> 5 hits, in docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
CONTROL, a nonsense term over the same paths, must be 0 -> 0
SC-020 grep -ril engagement docs/context/
-> docs/context/identity.md
CONTROL, a nonsense term over the same path, must be empty -> empty
Content is a PR-review item, and it was read rather than inferred from the green. The ADR
carries all three things SC-019 names, as named sections: the boundary as one presentation
(### 1. The boundary is one presentation: src/specify_cli/egress.py), the provenance invariant
(### 2. The provenance invariant …), and that the attribution guard is syntactic
(### 3. The attribution guard is SYNTACTIC — it is not an ownership proof). It also carries its
own "what this ADR does not prove" section. docs/context/identity.md's new engagement entry
defines the term and quotes the operator-facing refusal string verbatim.
That reading is recorded here as a reading. It is not what the gate measured, and a reviewer should not treat this paragraph as a substitute for their own.
SC-006 and FR-017 — CI routing is a parser reading, not an observed run
Every claim that WP03's discriminating module and WP02's routing edges select the right jobs
rests on tests/architectural/_gate_coverage.py's parse of .github/workflows/ci-quality.yml
plus a reading of each job's if: expression. The model uses pytest's own expression evaluator
and the two agree — but no CI run has been observed (FU-I). See what this mission did not
prove.
Docs lockfile reconciliation
This WP is the reconciler and it is last for exactly that reason: two shared 1:1 lockfiles over
docs/**/*.md cannot be owned by two lanes at once. WP06 added the ADR and edited
docs/context/identity.md and deliberately landed lockfile-dirty; this page is the second added
document. Both rows are reconciled here.
There are two lockfiles, not one. docs/development/3-2-docs-retrieval-index.yaml was in no
work package's owned_files and in no lane's write_scope (FU-N). Its drift is severity=error
via DOCS-INDEX-DRIFT and reds docs-freshness exactly as the page inventory does, and
freshen_adr_inventory.py contains zero references to it — verified by grep, not assumed. It
needs its own command.
Baseline, re-measured rather than transcribed
The WP prompts state a 685/685 baseline. Measured in a clean detached worktree at
985ae6484 — the tree immediately before the mission's squash merge:
docs/**/*.md 689
3-2-page-inventory.yaml committed rows 689
3-2-docs-retrieval-index.yaml committed rows 689
check_docs_freshness: exit=0 findings=0 errors=0 warnings=0
docs_structural_lint: checked 689 page(s); 0 violation(s).
freshen_adr_inventory --check --all: STALE (missing_rows=1 inventory_stale=False)
689, not 685, in both rulers. The number moved upstream between design and implementation (FU-O); it is not pre-existing drift. Note the last line: one ADR was already missing its era README row at baseline — see what was deliberately not fixed.
Before
Asserted in process at HEAD 673a718f0, immediately before writing:
docs/**/*.md 691 (baseline 689 + WP06's ADR + this page)
3-2-page-inventory.yaml committed rows 689
3-2-docs-retrieval-index.yaml committed rows 689
docs/adr/3.x/README.md index-table rows 94 (the ADR absent: 0 matches)
Five error rows, not the three the WP prompt implies:
ERROR LEAK-MISSING-INVENTORY docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
ERROR INVENTORY-INCOMPLETE docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
ERROR INVENTORY-LOCKFILE-DRIFT docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
ERROR DOCS-INDEX-DRIFT added docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
ERROR DOCS-INDEX-DRIFT changed docs/context/identity.md
check_docs_freshness: exit=1 findings=5 errors=5 warnings=0
The first three are three separate rules reporting the same missing page-inventory row, so the remediation is unchanged. This page's own row does not appear in the before-state because the page did not exist when that measurement was taken; it was added by the same regeneration.
The changed row on docs/context/identity.md is the engagement anchor FR-023 requires. WP06
measured that the updated/related frontmatter bump contributes nothing to that row;
independently confirmed here at the lockfile level — the regenerated retrieval index's diff for
that page is exactly one added line and no deletions:
+ - {slug: "engagement", text: "engagement", level: 3}
Across the whole retrieval-index diff: 67 insertions, 0 deletions. The index schema carries
title / divio_type / abstract / anchors and not updated or related, which is why the
frontmatter bump is invisible to it and the anchor is the entire cause.
The two commands
freshen_adr_inventory.py reconciles the page inventory and the era README table.
docs_index.py reconciles the retrieval index, which freshen_adr_inventory.py does not touch
at all — grep over that script returns zero references to docs_index or the retrieval index.
Both need PYTHONPATH=..
$ PYTHONPATH=. python scripts/docs/freshen_adr_inventory.py \
docs/adr/3.x/2026-08-04-1-egress-consent-boundary.md
README-ROW-ADDED 2026-08-04-1-egress-consent-boundary.md
freshen_adr_inventory: rows_added=1 inventory=regenerated
$ PYTHONPATH=. python scripts/docs/docs_index.py --write
docs_index: exit=0 generated=691 committed=691 drift=False (added=0 removed=0 changed=0)
The ADR's exact filename was taken from the tree, not guessed — git diff --name-status
against the baseline shows exactly one added file under docs/adr/. The explicit path was used
rather than --all deliberately: --all would also have written the pre-existing missing row.
After — drift=False is the evidence, with its counts
Taken at b07d7896b (originally measured on a dirty tree over 673a718f0, which is not a
reachable object; re-taken at the commit so a successor can reproduce it).
inventory_lockfile: exit=0 generated=691 committed=691 drift=False (added=0 removed=0 changed=0)
docs_index: exit=0 generated=691 committed=691 drift=False (added=0 removed=0 changed=0)
docs/**/*.md 691
3-2-page-inventory.yaml committed rows 691
3-2-docs-retrieval-index.yaml committed rows 691
docs/adr/3.x/README.md index-table rows 95 (the ADR present: 1 match)
this page present in the page inventory 1
this page present in the retrieval index 1
691 = 689 + 2, and the two are WP06's ADR and this page. No other page landed.
The gates:
check_docs_freshness: exit=0 findings=0 errors=0 warnings=0
docs_structural_lint: checked 691 page(s); 0 violation(s).
description_length_check: checked 542 page(s); 0 violation(s).
related_validator: checked 942 edge(s); 0 dangling.
Input counts alongside the greens — inventory_rows_count = 691 from the report JSON, 691
pages walked by the structural lint, 691 markdown files under docs/. A caution on that JSON: its
visible_paths_count and reference_entries_count fields read like input counts and are not —
:460 computes the first as the number of REF-* findings and :421 the second as the number
of findings in the CLI-reference payload. Both are 0 here because there are no findings, not
because nothing was checked. Do not quote either as an input count; inventory_rows_count is
the only genuine one in the payload.
One pre-existing gap, left alone on purpose
freshen_adr_inventory.py --check --all reports a second missing era-README row:
ADR-README-ROW-MISSING 2026-07-29-1-lane-base-recorded-planning-commit.md
It is pre-existing, measured at 985ae6484 where it is the only missing row and the
inventory is otherwise clean. ADR-README-ROW-MISSING is not one of
check_docs_freshness's rules, so it does not red docs-freshness and did not red the 689/689
baseline. It was not fixed here — it is another mission's row, and running --all to sweep it
up would have made this work package's diff quietly larger than its scope. Recorded instead of
silently absorbed or silently ignored.
Nothing was weakened, skipped, or allowlisted to get there. A lockfile made green by exempting its rows is the vacuous case, and it would have been indistinguishable in the output from this one.
Measurements that were wrong, and were re-taken
The greens above are the less instructive half of this record. These are the failures of measurement — recorded because in the output they are indistinguishable from real results.
Taken during this work package, and mine
| What happened | How it was caught | Disposition |
|---|---|---|
Nine reds in tests/architectural/test_gate_coverage.py under the system interpreter — 1 failure and 8 setup errors, every one reporting /usr/bin/python: No module named pytest from a subprocess. It looks exactly like a broken guard. |
Controlled against a known answer. _gate_coverage.py:1146 sets env["HOME"] = tempfile.mkdtemp(...) for isolation; the system interpreter's pytest lives in ~/.local/lib/python3.14/site-packages, i.e. the user site, which a replaced HOME makes unresolvable. Verified directly: HOME=$(mktemp -d) python -c "find_spec('pytest')" → False; the same probe in the 3.11 venv → True. |
Not a defect. An invalid measurement — the same shape as the mission's method note: a path that was not the one the measurer believed. Re-measured in venvs where pytest is HOME-independent: 37 passed on 3.11 and 37 passed on 3.14 — the second of those on the same interpreter version as the failing run, 3.14.4, differing only in where pytest lives. That is the controlled attribution: one variable changed, nine reds gone. |
-p no:randomly would have produced EXIT=4 — a pytest usage error, not a test result — because pytest-randomly is not installed here. |
Checked plugin presence before running, not after seeing a red. | Flag removed before any measurement was taken. This is the exact shape of the mission's "pytest reds that were EXIT=4 usage errors from a syntax-broken plant". |
A killed run. The 3.11 sweep was killed at exactly 10m00s — the harness timeout, not a test timeout — part-way through test_gate_coverage.py, with only progress dots and no F or E on the line. |
Elapsed time was compared against the timeout before attributing anything, because exit 143 is ambiguous between a timeout, a signal and a pipeline artefact. pytest.ini carries addopts = --tb=short and no --timeout (FU-6, re-measured OPEN), so a slow run consumes a run rather than failing it. |
A killed run is neither a pass nor a fail. Re-run narrowed to the seven remaining files, in the background, with no harness timeout. The partial output is quoted nowhere as evidence — and note that the re-run overwrote it, so the exact dot count is no longer verifiable and is deliberately not stated. What matters is that it was re-run, not explained. |
The summary extractor that reads N passed out of each output file. |
Controlled on a purpose-built file with one passing and one failing test before being pointed at real runs. It reported 1 failed, 1 passed and rc=1. |
A green-only extractor would have silently converted every red below into a pass. It does not. |
freshen_adr_inventory.py --check reports missing_rows=0 with no paths and no --all — a green over an empty target set. |
Controlled by re-running the same command with --all, which returned missing_rows=2. |
A sixth proof that could not fail, in the tooling rather than in a test. --check alone is not a verification of the ADR index; --check --all or --check <path> is. Recorded because the bare form is the one a reader reaches for. |
The is_dir() EACCES probe used to attribute the 3.14 ownership failures. |
Its CONTROL row is a readable directory, asserted to return True on both interpreters before the 0o000 row was read; euid checked as non-zero so the branch is reachable at all. |
Without the control, "3.14 returned False" is indistinguishable from a probe that returns False unconditionally. |
A control that did not fire, and was not waved away. Because this page is itself an indexed doc, every edit to it can re-dirty the retrieval index — so each editing round was followed by a drift check expected to be dirty before regenerating. One round came back drift=False before regeneration, which is what a broken detector looks like. |
Explained from the schema rather than assumed benign: the index carries title / divio_type / abstract / anchors only. The round that drifted had added new ### headings (new anchors); the round that did not had added only body prose under existing headings. And the detector was already proven able to fire earlier the same session — it reported DOCS-INDEX-DRIFT changed for this page, then drift=False after --write. |
The correct reading of a silent control is "find out why", not "good". Here the silence was correct. The order matters and is easy to get backwards: regenerate the lockfiles last, after the page content is final, or the page you just edited drifts the index you just wrote. |
Corrections to the WP prompt's own stated numbers and paths, re-measured here
| Prompt says | Measured | Where |
|---|---|---|
docs_structural_lint baseline 685 pages, 0 violations; inventory_lockfile 685/685 |
689 pages, 0 violations and 689/689, check_docs_freshness: exit=0 findings=0 |
At 985ae6484, in a clean detached worktree. The number moved upstream between design and implementation (FU-O). |
| "Two pages are added by this mission and both rows are yours" ⇒ three error rows | Five error rows. LEAK-MISSING-INVENTORY, INVENTORY-INCOMPLETE and INVENTORY-LOCKFILE-DRIFT all name the ADR; DOCS-INDEX-DRIFT added names the ADR and DOCS-INDEX-DRIFT changed names docs/context/identity.md. |
The first three are separate rules cleared by the same page-inventory regeneration, so the remediation is unchanged. Five where three were expected is not a new defect. |
Styleguide at src/doctrine/styleguides/built-in/common-docs.styleguide.yaml |
That path does not exist. The live file is packs/built-in/styleguides/common-docs.styleguide.yaml, and it is the path .github/workflows/docs-freshness.yml passes to --styleguide. |
The cited line numbers still resolve correctly in the real file. |
WP06 "edits two docs/context/ pages" |
One. git diff --name-status shows only docs/context/identity.md under docs/context/. |
Consistent with there being exactly one DOCS-INDEX-DRIFT changed row. |
python scripts/docs/inventory_lockfile.py --write docs/development/3-2-page-inventory.yaml |
Runs, but contradicts the module's own stated contract: inventory_lockfile.py:24-25 says "This module never writes to docs/ or to the inventory", and --write's help reads "(never docs/)". Nothing enforces it — :416-419 writes wherever it is pointed. |
The sanctioned writer of that lockfile is freshen_adr_inventory.py, which regenerates it and the era README table. That is what was used. |
python scripts/docs/… with no PYTHONPATH |
Both inventory_lockfile.py and docs_index.py die on import with ModuleNotFoundError: No module named 'scripts' unless PYTHONPATH=. is set. The workflow sets it; the prompt's commands omit it. |
— |
docs-freshness.yml is "the one workflow with no base-branch filter" |
It is one of three: canonical-producer-lint.yml, docs-freshness.yml, plugin-validate.yml. Partition over all 17 workflow files: 8 base-branch-filtered + 3 PR-triggered-unfiltered + 6 not PR-triggered. |
The substantive point still holds — docs-freshness would have red on a per-lane PR, and no lane opened one. But it is not escape-proof: its job carries if: !contains(labels, 'pr:deferred') && !contains(labels, 'pr:skip-ci'), so a label does skip it. No lane reached for one. |
| — (my own probe, not the prompt's) | My first pass at that partition reported docs-freshness.yml as not PR-triggered, because on: pull_request: with an empty body parses to None and my condition tested pr is not None. |
Caught by making the partition exhaustive — 8 + 3 + 6 = 17 — instead of printing only the matches. A filter that silently drops a case looks identical to a case that is not there. |
Taken earlier in the mission — attributed, not mine
These come from the mission's own method note in follow-ups.md and from the operator's WP07
dispatch. They are recorded because they are the mission's most transferable output, and
attributed because this page did not measure them.
- Five invalid verifications shared one shape: an exit status or a path that was not the one
the measurer believed. (i)
EXIT=$?placed after a$(...)reported the command substitution's status, not the command's. (ii) pytest "reds" that wereEXIT=4usage errors raised by a syntax-broken plant, never a test result. (iii) A reviewer whose shellcwddefaulted to a different lane than the one under review — for a full round. (iv)HOMEreplacement relocatingpytestto the user site, producing nine spurious reds intest_gate_coverage.py— same interpreter in a venv gives37 passed. (v) Backticks inside a double-quoted shell string executing as command substitution. - THIS PAGE'S OWN EVENT RECORD IS A CASUALTY OF (v), AND THAT MATTERS FOR FU-Q. WP07's
for_reviewnote in the append-onlystatus.events.jsonlhad its mechanism phrase deleted: it now reads "hangs off under resolved.is_dir()". The event log cannot be rewritten, so this page andfollow-ups.mdare authoritative for FU-Q, not the event note. The claim survived; only the mechanism was lost, which bounds the damage but does not remove it. - A sixth instance, and it is in the tooling rather than a measurer:
freshen_adr_inventory.py --checkwithout--allreportsmissing_rows=0over an empty target set — a gate that passes because it inspected nothing.--check --allreportsmissing_rows=1, rc=1. - The method that caught all of them: assert the environment in process, before measuring.
assert LANE_C in ownership.__file__;git rev-parse HEADcompared against the expected commit; an anti-vacuity assertion placed before the value being reported. Every measurement on this page follows it, which is why each one names its tree and interpreter. - A red attributed to the wrong assertion, and reported as proof that a fix worked. The run really was red; the assertion that produced the red was not the one the fix touched.
- Five instances of a proof that could not fail — including two anti-vacuity controls that were themselves vacuous. This is the mission's signature defect, and it is why every check on this page that could be vacuous carries an explicit control whose result is quoted alongside it.
Conditionality ledger — what a successor must re-take
| Evidence | Conditional on | What that means |
|---|---|---|
| Any full-suite green on this surface | #3115 — OPEN, sync half deferred to #3136 (FU-G) |
Not attributable. Only the T038 isolated single-file runs count, and no full-suite green is claimed on this page. |
Any green from test_egress_consent_boundary.py on a moved sink (WP03) |
#3113 |
_transmits_a_body derives kwargs solely from node.keywords, so a fully positional poster(url, data, headers) is not classified as a sink. #3113 is CLOSED by non-adoption, not deferral (FU-D): the matcher tightening was declined at a measured cost, and two positional shapes are pinned xfail(strict=True). The blind spot survives by decision. |
The attribution guards (test_client_consent_gate_3030.py, test_saas_client_consent_gate_3030.py) |
Nothing — explicitly not #3113 (R-12 / D-10) |
They match by class name and count every match regardless of call form. Do not re-take these, and do not credit any coverage claim here to #3113. Their real bound is the literal class-name match: an aliased import, a factory, or an injected transport is invisible to them, and both modules say so in the constant next to the floor. |
SC-006's [one-off] CI observation (WP02/T015) |
Necessarily post-merge | ci-quality.yml is itself the first entry of the core_misc glob list, so any PR editing it selects fast-tests-core-misc for that reason. A core_misc-green run on the mission PR is a tautology that looks exactly like proof. See post-merge carriers. |
Observing the specify-cli-rest shard execute |
Necessarily post-merge (FU-I) | See post-merge carriers. |
| A killed or timed-out run | FU-6 — re-measured OPEN | pytest.ini has no global --timeout, so a hang consumes a run rather than failing it. One killed run occurred here and was re-run narrowed. |
test_ownership_3111.py's green |
The interpreter — it is a 3.11/3.12 green only, and note the sharper form: no CI job runs the test suite on 3.13+. CI does provision 3.13 in two jobs — build-wheel (ci-quality.yml:3848) and clean-install-verification (:3898) — and neither runs pytest (verified by parsing both job bodies; census across 17 workflows is 3×3.11, 58×3.12, 2×3.13). An earlier draft said "CI runs only 3.11/3.12", which was false and understated the exposure: clean-install-verification imports specify_cli and runs spec-kitty next on 3.13, so 3.13 is a supported surface that no suite covers |
33 passed on 3.11; 2 failed, 31 passed on 3.14, same pytest 9.0.3. The two failing tests are correct and the module under test is wrong on 3.13+. Do not re-take this as a flake and do not widen the tests to accept () — see the divergence the 3.11 run actually found. Open, escalated, and filed as #3177 so it has an owner past this dossier. |
| WP04's containment checks against a bind mount | Nothing will fix this at this level (FU-E) | mount --bind is transparent to realpath, needs root to arrange, and is indistinguishable from a copy at the VFS layer, so there is no second path to compare against. Reasoned, not measured. An inherent limit of path-based containment, not a defect. |
_repo_relative / _saas_site_attribution / _tracker_site_attribution pins |
A helper-bypass bound (FU-M) | Each helper is single-source by construction, which is what lets its pin fail. A future edit that re-implements rather than calls one of them is outside what any runtime assertion can observe. No action implied — recorded so the pins are not mistaken for coverage of that case, and not "strengthened" in a way that cannot work. |
What this mission did not prove
Stated positively, because a limit recorded as an absence is a limit nobody finds.
- No CI run was observed. The entire CI-routing model — SC-006, FR-017, WP02's two change-group
edges, the
clidisjunct onfast-tests-core-misc— is a parser reading of workflow YAML plus a reading of each job'sif:. The parse and the reading agree. Neither is a run. - The
specify-cli-restshard was never watched executing (FU-I). Every claim that WP03's discriminating module runs rests on the model above. - No full-suite green exists for this mission, by design (
#3115). Every green here is an isolated single-file run. - Bind-mount invisibility is reasoned, not measured (FU-E).
#3113's all-positional blind spot is open by decision (FU-D). Non-adoption is the resolution, not a deferral — a successor should not read it as pending work.- The grep gates (SC-019, SC-020) prove presence, not content. Content is a PR-review item.
- The
[ratchet]criteria prove continuity, not construction. SC-004 clauses 1–2, SC-010, SC-021 and all five NFR-004 pins already held atbb2020fea. Only SC-004 clause 3 discriminates. acceptance-matrix.jsonis an unpopulated stub atHEAD— every criterion readspendingwith"notes": "TODO: replace with a real acceptance criterion". It is not this work package's file and was not edited; noted so nobody reads it as a verdict.- This mission halts at design for
#3113and#3115. Neither is fixed here. This page is where Bundle A's cost is written down. - Nothing was measured on 3.12. CI runs 3.11 and 3.12; only 3.11 was built and run here,
which is what NFR-006 asks for in executed form. 3.12 shares 3.11's
is_dir()EACCES behaviour according toownership.py's own docstring table — that is a reading of someone else's measurement, not one taken here. - The 3.13+ ownership defect is reported, not fixed. WP07 writes no source and no tests. Its remedy is escalation, and it is open at the time this page was written.
Post-merge carriers
Three obligations survive the merge. Two have homes; the third does not yet, and is named here precisely so its homelessness is on the record rather than discovered later.
0. tracer-tooling-friction.md — NO HOME YET
Charter standing order 3 asks for a friction tracer. The dossier has
tracer-evidence-base.md and tracer-squad-findings.md and not this one. WP07's contract
assigns creating it to the orchestrator after this WP merges, and finalize-tasks rejects
the owned_files entry that would let WP07 write it — so it could not be created here even
deliberately. Recorded as a third surviving obligation rather than left to be noticed.
The raw material already exists: the five invalid-measurement instances above, plus the empty-target-set gate, are exactly what a friction tracer is for.
1. SC-006's [one-off] half — folded into the repo
The carrier is the docstring of
test_ci_quality_workflow_file_is_itself_a_core_misc_glob, which reds if the confound ever
disappears. It states what is owed, why the mission PR cannot supply it, why the stacked-PR
substitute is inoperative, the procedure
(packs/built-in/procedures/post-merge-arch-gate-adjudication.procedure.yaml) and an
accept/reject condition checkable by a stranger.
It is necessarily post-merge: .github/workflows/ci-quality.yml is itself the first entry of
the core_misc glob list, so any PR editing it selects fast-tests-core-misc for that reason
— a tautology that looks exactly like proof. This is not a deferral for convenience; the
in-mission observation is structurally unobtainable.
2. Observing the specify-cli-rest shard actually execute (FU-I)
Owed, unowned by any pre-merge step, and the last inch of WP03's routing claim. Watching that shard run is what converts the parser reading into an observation.
Friction tracer
Consolidated from follow-ups.md (the implementation-phase record) plus this work package's own
measurements. Full text and falsifiers live in
kitty-specs/egress-refusal-consolidation-3110-01KYW895/follow-ups.md; this is the index a
successor reads first.
Filed upstream — not this mission's code
- FU-A —
active_job_keysdoes not gate onon.pull_request.branches. Pre-existing, and it errs toward over-approximating selection, the direction that makes a positive assertion easier to pass. Harmless for every assertion this mission makes; noted so it is not rediscovered as a WP02 defect. - FU-B — the dead qualname comparison in both
invocation/adapters.pyregistrars: both arms assignfn, so the comparison changes only the path taken to an identical assignment. WP05 corrected the docstring that misdescribed it and left the code, because this seam was scoped to be kept and pinned, not refactored. - FU-C — the upstream ownership-validator defect: the documented "planning-artifact WP that legitimately owns nothing" has a docstring and a regression test but no path to a green run — the manifest builder drops any WP with an empty ownership list and the lane computer then treats the missing manifest as a hard error.
- FU-P — a profile-wrapper contradiction:
curator-carla's generated wrapper carries a read-only "Hard boundary" clause contradicting WP06's ownrole: implementer/execution_mode: code_change. Reported rather than silently resolved. Looks like a reviewer-slot default leaking into an implementer dispatch; file against the profile generator.
Carried residuals — recorded, not fixed
FU-D (#3113 non-adoption) · FU-E (bind mounts) · FU-F (SC-015's scan scope diverged
from the spec and was reconciled toward the spec; the spec and the WP contract still disagree
in text) · FU-G (#3115's sync half open, deferred to #3136) · FU-M (the
helper-bypass bound). Each is in the conditionality
ledger with what it does and does not
bound.
Residual remediation, frozen under an explicit operator decision
All four were found by review, graded LOW, and are fail-closed. None is a fix-round item.
- FU-J — a regression introduced by a fix, stated plainly. LOW-4 re-keyed the remedy onto
unreadable_ledgersalone, which correctly fixed the likelier EACCES-mixed row — but that tuple conflates malformed with unreadable (the module's own docstring says so atownership.py:87-89), so a corrupt-ledger operator is now told tochmod u+rx, where no mode bit is wrong. Measured on both the corrupt-JSON and schema-invalid rows. Same wrong-operator-action class as LOW-6/7/1, moved rather than removed. Net still a win, and the point of recording it is that "I fixed the diagnosis" was the claim and it was only two-thirds true. - FU-K — the AST guard's try-context rule ignores the handler type:
try: p.stat()/except ValueError:satisfies it whileOSErrorstill escapes. No live defect (all three real handlers catchOSError), and the docstring states what is implemented rather than overclaiming. - FU-L —
unreadable_ledgersis a published field with mixed-kind contents: it now carries mission-directory names as well as ledger names and is exposed under that key in the--dry-runpayload. LOW-2 corrected the prose; the field name and JSON key were not touched, so a machine consumer parsing it as ledger paths still gets a wrong answer. - FU-N / FU-O — the second lockfile and the stale baseline. Both discharged by this work package; see Docs lockfile reconciliation.
Found by this work package — open, and escalated rather than absorbed
- The 3.13+
is_dir()regression inownership.py:387-403. LOW-8's recording of a dropped mission candidate hangs offexcept OSError, and on 3.13+Path.is_dir()swallows EACCES instead of raising, so the drop is never recorded and the operator is told "no missions were found … rungit pull" for a permission problem — LOW-8's original defect, verbatim. Green on CI's interpreters, wrong on the only locally installed one. Fail-closed either way; a diagnosis defect, not a consent leak. Owner WP04, which isapproved, so the route is escalation. Full analysis, mechanism probe and candidate fix: the divergence the 3.11 run actually found. - A pre-existing missing ADR era-README row (
2026-07-29-1-lane-base-recorded-planning-commit.md), measured at baseline, outsidecheck_docs_freshness's rule set, deliberately not swept up. See one pre-existing gap. freshen_adr_inventory.py --checkis vacuous without--allor a path — it reportsmissing_rows=0over an empty target set. Tooling, not test code, and the same shape as the mission's five proofs-that-could-not-fail.
Tooling friction met while producing this page
pytestcollection costs roughly 50–60 s per invocation on this tree before a single test body runs, so nine isolated single-file runs cost about nine minutes in fixed overhead alone.tests/architectural/test_gate_coverage.pyis far worse: it spawns a--collect-onlysubprocess per CI gate over the whole test tree.- With no global
pytesttimeout (FU-6), that cost is what turns a harness timeout into a consumed run rather than a failure — the mechanism behind the one killed run recorded above. scripts/docs/inventory_lockfile.pyandscripts/docs/docs_index.pyare not runnable as bare scripts; both needPYTHONPATH=..check_docs_freshness.pyfails closed withENV-SAAS-SYNC-OFF(exit 3) unlessSPEC_KITTY_ENABLE_SAAS_SYNC=1is set at import time. A run missing it is not a clean run; it is no run.