Files
ersatztv/docs/remote-state-inventory.md
T
timothyandClaude Opus 5 84b4c11188
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
review-verdict/h10 Awaiting review verdict for 84b4c11
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 5m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
fix(778): round 2 — recursive population, and grade down five more overclaims
Second cold review: no Blockers, 4 High / 1 Medium / 2 Low. All accepted.

POPULATION, WRONG A SECOND TIME. Round 1 removed a content filter that had
omitted `git fetch`. Round 2 found the replacement traversal used non-recursive
`Path.glob`, so four nested files were still outside it — including
scripts/scripted-schedules/entrypoint.py, which calls get_context() against a live
ErsatzTV server and then drives define_content/reset_playout/build_playout off the
result. Now rglob, with scripts/tests/ as the single stated DIRECTORY-level
exclusion (a scope choice, reviewable in one line; not a predicate over content).
Population 55 -> 59, rows 63 -> 68.

The generalisation is in the record, because the deliverable made the same mistake
twice: the scope may be hand-written, but anything narrowing the POPULATION has to
be executed and its output compared against the filesystem — the members it drops
are invisible by construction. That is the #774 rule turned on the artifact meant
to enforce it.

FIVE MORE OVERCLAIMS GRADED DOWN. Both merge-consent head/base rows (the hook
returns `allow` and a separate call merges, so the window is small, not absent —
"no async window" was simply false); the release smoke pull and the ci-image verify
(the concurrency group is PER-REF, so a branch build and a tag build of one commit
can publish the same :<short-sha>); and the workflow base-fetch rows, which are not
advisory — the merge hook reads the COMBINED status, so any red context blocks the
auto-grant. Also fixed a stale cross-reference where the enumeration row still said
it "inherits that row's pins" from a row graded down in the same commit.

Four PINNED rows survive out of 68. That ratio is the honest finding.

CIRCULAR JUSTIFICATION REMOVED. The scheduled-merge row said its residual was
"closed one layer down" by the very branch protection an admin may have removed.
It is not closed, it is BOUNDED by a trust assumption, and the row now says so.

Low: jq's `//` fires on `false` as well as null, so `status_check_contexts: false`
was defaulted to [] and produced a confident deny from a shape never understood —
absent and null are now defaulted explicitly, everything else is "unknown". And the
title sentence claimed "every executable in this repo" while the guard covers four
directories; both it and the docs/README entry now say what is actually enforced.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 13:44:10 +02:00

27 KiB

Remote-state inventory (ersatztv#778)

Every executable in the four scoped directories (see limit 3) that reads live remote state and later acts on that read, and whether the read is bound to something that cannot change underneath it.

The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every executable in this repo" while its own derivation missed real remote readers, so the title now says what the guard actually enforces. C#/TypeScript guards, web/, and anything outside those four directories are not covered — stated in limit 2.

scripts/tests/test_remote_state_inventory.py derives the population from the filesystem and asserts set equality against the Site column, so a new script that talks to a remote service cannot ship without acquiring a row.

Read docs/decisions/records/process/check-and-use-pins-a-version.md before adding a row or changing a classification.

Why this file exists rather than a linter

docs/defect-shapes-773.md §4 detector D is a fix pattern, not a mechanical detector: there is no general lint for "this code should have pinned a sha." What makes the class actionable is that the population is small and enumerable, so the detector is detector A — derive the population from an authoritative source and assert set equality — applied to this inventory. The inventory is the artifact; the test keeps it from rotting.

Columns

  • Class
    • PINNED — the read is bound to an immutable version identifier (a full commit sha, an image digest, a monotonic event count) and the action re-validates against that identifier before it commits. Binding alone is not enough; a snapshot nobody re-checks is not pinned.
    • CAS — the write itself carries a compare-and-set condition the server enforces.
    • UNSAFE-KNOWN — read-then-act with nothing pinning it, accepted with the reason stated in the Note. Every row here must say why the residual is tolerable, not merely that it exists.
    • N/A — reads no live remote state, or draws no authorization from what it reads.
  • Note — the window, and what closes or bounds it.

A row is about a site, not a file: a file with two independent reads gets two rows only where the classifications differ; otherwise the strictest applies and the Note names the exception.

The inventory

Hooks

Site Class Note
.claude/hooks/pretooluse-merge-consent.sh — head-sha reads (CI status, H10 status, verdict comments) UNSAFE-KNOWN Every comparison uses the full 40-char .head.sha (${sha:0:7} appears only in human-readable strings), which is the right identifier — but the hook returns allow and the merge is executed afterwards by a separate call, so the window is small rather than absent. "No async window" was the second overclaim cold review removed from this file. The merge API accepts an optional head_commit_id, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head.
.claude/hooks/pretooluse-merge-consent.sh — scheduled auto-merge (merge_when_checks_succeed) UNSAFE-KNOWN Preflight, not a pin — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it PINNED contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that review-verdict/h10 is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection after the read. Accepted, and the earlier wording here was circular — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add head_commit_id to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does.
.claude/hooks/pretooluse-merge-consent.sh — base-retarget detection UNSAFE-KNOWN recorded_base parsed from the H10 status description is compared against the PR's live .base.ref (#632), and it deliberately compares the base ref rather than base.sha — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook.
.claude/hooks/pretooluse-merge-consent.sh## Done-when issue-body read UNSAFE-KNOWN The issue body carries an updated_at that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode.
.claude/hooks/prepush-donewhen.sh## Done-when issue-body read UNSAFE-KNOWN No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: main carries enable_push: false and block_admin_merge_override: true, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses.
.claude/hooks/pretooluse-nav-guard.sh N/A Reads only the proposed tool call's own parameters and decides synchronously; the curl mentions in the file are prose, not executed lines.

| .claude/hooks/prepush-rebase-check.shgit fetch origin main, then git merge-base --is-ancestor origin/main HEAD to decide whether to block the push as behind | UNSAFE-KNOWN | Fetch-then-decide with no re-validation before the verdict; origin/main can advance inside that window. Accepted because the decision is self-correcting and cannot reach main: a push allowed on a now-stale read still lands on a feature branch, since direct pushes to main are refused server-side (#743), so the worst case is a rebase nag arriving one push later rather than a bad merge. This row exists because the previous token filter did not list git fetch and so could not see it at all. | | .claude/hooks/prepush-clean-worktree-check.shgit fetch origin main, then diffs origin/main...HEAD to scope which dirty files are in the pushed diff | UNSAFE-KNOWN | Same fetch-then-decide shape and the same bound: the verdict advises a feature-branch push only, main refuses direct pushes server-side (#743), and a stale origin/main read at worst lets a dirty-file push through, which the downstream review gate still catches before any merge. | | .claude/hooks/decisions-guard.sh | N/A | Reads no live remote state — runs scripts/decisions_validate.py over the local working tree; no fetch, no HTTP call. | | .claude/hooks/design-sync-reminder.sh | N/A | Reads no live remote state — compares local git diff/ls-files output against the session's own edits, never contacts origin. | | .claude/hooks/posttooluse-worktree-marker.sh | N/A | Reads no live remote state — parses the tool call's own JSON payload and writes a local ownership marker. | | .claude/hooks/pretooluse-agent-model.sh | N/A | Reads no live remote state — inspects only the proposed Agent call's own model/subagent_type fields. | | .claude/hooks/pretooluse-agent-ram.sh | N/A | Reads no live remote state — samples local memory_pressure -Q output. | | .claude/hooks/pretooluse-bash-guard.sh | N/A | Reads no live remote state — pattern-matches the proposed Bash command string for ETV_UPDATE_GOLDENS=. | | .claude/hooks/pretooluse-bom-guard.sh | N/A | Reads no live remote state — inspects local git diff output and reads local .cs bytes for a BOM. | | .claude/hooks/pretooluse-worktree-guard.sh | N/A | Reads no live remote state — reads a local .claude-worktree-owner marker in the target worktree. |

Husky git hooks

Site Class Note
.husky/pre-push N/A Delegates every remote read to prepush-donewhen.sh, prepush-rebase-check.sh and prepush-clean-worktree-check.sh, each of which carries its own row. This file forwards stdin ref lines and runs local npm run check:api/lint/typecheck/build against the checked-out tree.
.husky/pre-commit N/A Reads no live remote state — local lint-staged, decisions-guard.sh, a git diff --cached scan, and local dotnet format --verify-no-changes.
.husky/commit-msg N/A Reads no live remote state — greps the local commit-message file for a trailer.

Scripts

Site Class Note
scripts/post-review-verdict.sh — commit-status write PINNED Re-reads the PR and compares both .head.sha and .base.ref immediately before the POST, and dies (exit 1, no status written) on a mismatch or on a field it cannot read. That last clause is new: both comparisons were guarded by [ -n "$x" ] &&, so a well-formed 2xx body that merely omitted the field made the check a no-op and the status was posted having confirmed nothing — found by cold review on #778 and regression-tested against the real predecessor, since the redundant -z arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual: the comment is posted before the re-read, so a head that moves in between leaves a verdict comment with no status — the comment is not the gate, but the mismatch is confusing and is tracked in #792.
scripts/pr-changed-files.sh — paged file enumeration UNSAFE-KNOWN #707's fix, graded honestly after cold review: .base.ref, .base.sha and .head.sha are captured before paging and re-checked after, and any observed movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is ABA-vulnerable — a main → scratch → main retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone: the answer is the caller-side monotonic event-count fence (ci.verdict-write-retarget-fence), which counts change_target_branch events precisely because a name can alias and a count cannot.
scripts/select-queue.sh — issue list, then per-issue /dependencies UNSAFE-KNOWN The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed in-progress in that gap still appears on the shortlist. Accepted: the script authorizes no write. The real gate is the four-way claim check in process.parallel-session-claim, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory.
scripts/ci-detect-already-validated.sh — prior-head combined status UNSAFE-KNOWN Reads the PR head's status and emits skip=true, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only re-running test/migrations on a tree already validated; the build job still builds and pushes unconditionally, so no image ever ships from unvalidated source.
scripts/issue-qualification-audit.sh N/A Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge.
scripts/security-scan.sh N/A docker pull of a mutable tag, then scans exactly what was pulled. Resolution and use are one step — no check-then-use split.
scripts/migration-smoke.sh N/A Same shape. The resolved image id is reported for the operator rather than compared against a prior read, which is an operator-trust gap (the script says so) and not a race.
scripts/hook-fire-log.sh N/A Entirely local: reads stdin and writes JSONL under the cache dir; the only curl in the file is in a comment.
scripts/e2e-local.sh N/A No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started.
scripts/e2e-ui.sh N/A Launches a local Chromium and runs specs against http://localhost:$PORT.
scripts/e2e-functional.sh N/A Every call targets $BASE_URL, defaulting to http://localhost:8409. The one non-local-looking address, 192.0.2.1, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script.

| scripts/ci-detect-docs-only.shgit fetch origin "$base", then diffs the fetched tip against HEAD to emit docs_only, which gates whether the required test/migrations jobs run their real steps | UNSAFE-KNOWN | Read-then-act with no re-check between the fetch and the emitted value, and the decision genuinely gates required CI work. Accepted because the script is deliberately asymmetric: every ambiguous, undeterminable or shallow-checkout case resolves to docs_only=false (run everything), and only an exact unanimous all-docs diff yields true — so a stale or racing base read can at worst cause an unnecessary full run, never a skipped one (#416). | | scripts/refresh-shared-checkout.shgit fetch origin main, then git merge --ff-only origin/main and a conditional npm ci | UNSAFE-KNOWN | Fetch-then-act with no re-check between the fetch and the merge. Accepted because every action is self-refusing or reversible: --ff-only fails harmlessly rather than diverging if the ref moved on, the script refuses outright when the tree is not clean main or is ahead or mid-rebase, and this is a developer-convenience checkout rather than a release or merge-authorization path — a stale read costs one extra fetch next run, never lost work. | | scripts/check-review-verdict.sh | N/A | Reads no live remote state itself — classifies a comments JSON payload supplied on stdin; the fetch belongs to the caller's row. | | scripts/decisions_validate.py | N/A | Reads no live remote state — its git log/show/ls-tree/merge-base calls operate on refs the caller already checked out or passed via --base/--head, never a fetch. | | scripts/prove-fix.sh | N/A | Reads no live remote state — git worktree add/rev-parse/diff-tree operate on the local repository's own objects. | | scripts/add-migration.sh | N/A | Reads no live remote state — runs dotnet ef migrations add against local project files; implicit NuGet resolution is dependency supply-chain, out of this class per limit 1. | | scripts/update-openapi.sh | N/A | Reads no live remote state — a local dotnet build/GenerateOpenApiDocuments then a local python script. | | scripts/cleanup-code.sh | N/A | dotnet tool restore resolves and uses tooling in one step (limit 1, not a check-and-use split); the rest is a local git status --porcelain scan. | | scripts/cleanup-all-code.sh | N/A | Same shape as cleanup-code.sh — restore-and-use in one step, no check-then-act over remote state. | | scripts/build_decisions_catalog.py | N/A | Reads no live remote state — parses local decision records and writes the local catalog. | | scripts/decisions_lib.py | N/A | Reads no live remote state — pure parser over local decision-record files. | | scripts/migrate_decisions_split.py | N/A | Reads no live remote state — one-shot local file migration over docs/decisions/. | | scripts/generate-endpoint-index.py | N/A | Reads no live remote state — reads local v1.json and writes a local markdown index. | | scripts/check-kickoff-guard.sh | N/A | Reads no live remote state — scans a fixed local file list for forbidden phrasing. | | scripts/check-local-lsp.sh | N/A | Reads no live remote state — probes local PATH binaries and spawns a local MCP server over stdio. | | scripts/mcp_smoke.py | N/A | Reads no live remote state — spawns a local subprocess and speaks JSON-RPC over stdio pipes, no network socket. | | scripts/jq-preflight.sh | N/A | Reads no live remote state — runs local jq --version. | | scripts/ci-peak-anon.sh | N/A | Reads no live remote state — samples the runner's local cgroup memory.stat/memory.peak. | | scripts/ci-prove-ban-detects.sh | N/A | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. | | scripts/ci-step-ran.sh | N/A | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. | | scripts/set-provider.sh | N/A | Reads no live remote state — sets local dotnet user-secrets values. | | scripts/__init__.py | N/A | Empty package marker — executes nothing. | | scripts/scripted-schedules/entrypoint.pyScriptedScheduleApi.get_context(build_id), then define_content / reset_playout / build_playout against the same live server | UNSAFE-KNOWN | A genuine read-then-act over live ErsatzTV state, and the row cold review found missing when the population was still non-recursive. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. | | scripts/macOS/bundle.sh | N/A | Reads no live remote state — moves files and creates symlinks in a local app bundle. | | scripts/macOS/sign.sh | N/A | codesign --timestamp contacts Apple's timestamp server, but resolution and use are one step with no earlier check whose answer is later trusted — the same boundary as limit 1. | | scripts/macOS/sign-dmg.sh | N/A | Same shape as sign.sh — a timestamped codesign over a local DMG, no check-then-act over remote state. |

Workflows

Site Class Note
.gitea/workflows/review-verdict.yml — status read → status POST UNSAFE-KNOWN The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic change_target_branch event-count fence refuses to write if the count moved (ci.verdict-write-retarget-fence; the count is used because the branch name is ABA-vulnerable), and a high-water-mark re-read repairs a success posted over a human verdict back to pending. The file states the residual window explicitly rather than asserting safety.
.gitea/workflows/review-verdict.yml — base-ref checkout PINNED ref: ${{ github.event.pull_request.base.sha }} — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it.
.gitea/workflows/review-verdict.yml — changed-file enumeration UNSAFE-KNOWN Delegates to scripts/pr-changed-files.sh with the head sha and expected base, and therefore inherits that row's residual, not a pin — this row said "inherits that row's pins" while the row it points at was being graded down, which is exactly the stale-cross-reference a multi-round edit produces. Accepted on better terms than the enumerator alone, though: this is the one caller that also runs the monotonic change_target_branch event-count fence (ci.verdict-write-retarget-fence), which is precisely the control that catches the ABA the enumerator cannot see.
.gitea/workflows/docker-build.yml — CI toolchain image UNSAFE-KNOWN Graded down by cold review, correctly: this file's own definition of PINNED names an image digest, and ersatztv-ci:<short-sha> is a mutable tag. A registry tag can be repointed after ci-image-pin verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume :latest, and pr-checks.yml's ci-image-pin fails the build if the tag drifts from the last commit touching docker/ci/. Consuming image@sha256:… is the real fix and is the natural companion to #772, which already covers the availability half of this tag's weakness.
.gitea/workflows/docker-build.yml — release smoke pull UNSAFE-KNOWN Pulls ${IMAGE}:${SMOKE_SHORT_SHA}, the tag this same job pushed moments earlier. The first draft called that PINNED on the strength of the job's concurrency group; cold review showed the group is per-ref, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same :<short-sha> — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull image@sha256:…, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned.
.gitea/workflows/docker-build.ymlapi-docs / format base fetch UNSAFE-KNOWN Fetches the live base tip to diff generated artifacts, with nothing pinning it. Graded up from N/A by cold review, which was right that "advisory" was too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's combined status, and a combined state that is not success blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not.
.gitea/workflows/ci-image.yml — verify the pushed image UNSAFE-KNOWN Pulls back the :<sha> tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies.
.gitea/workflows/pr-checks.ymlprove-fix PINNED base/head are full shas from the event payload, immune to later PR mutation.
.gitea/workflows/pr-checks.ymlci-image-pin N/A A purely local comparison — git log over the checked-out tree against a literal in docker-build.yml. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe.
.gitea/workflows/pr-checks.ymldocs-reminder / decisions-guard base fetch UNSAFE-KNOWN Both fetch the live base tip and diff against it, unpinned. docs-reminder is explicitly non-blocking; decisions-guard can fail its job and therefore reaches the combined status the merge hook reads. Accepted on the same grounds as the api-docs/format row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and both re-fetch fresh on every trigger with no state carried between runs.
.gitea/workflows/renovate.ymlrenovate/renovate:43 N/A Out of this class, in scope for a different one — see the limits below.
.gitea/workflows/dependency-scan.yml — NuGet advisory query N/A dotnet list package --vulnerable queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories as of this run", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs.

Limits, stated rather than implied

  1. Unpinned dependencies are a different class and are not graded here. actions/checkout@v4, docker/build-push-action@v6, mysql:8.4 and renovate/renovate:43 are floating tags, and renovate.yml runs its one with a repo-writing token. But resolution and execution are the same step — there is no earlier check whose answer a later action trusts — so they are a supply-chain-pinning concern, not a check-and-use race. Listing them as N/A here records that they were examined and classified, not that they are safe.

  2. The population is files, not call sites. The test derives which files are in scope; it cannot tell that an existing file grew a second, unpinned read. That residue is the sites-in-code limit named in testing.guard-derives-population-from-source — it needs find-all-references tooling, tracked in #777 — and it is why the Note column is prose a reviewer reads rather than a field a script checks.

  3. Scope is hand-written; the population inside it is derived, with no content filter at all. Scope is every file under scripts/ (*.sh, *.py), .claude/hooks/, .husky/ and .gitea/workflows/ — a reviewed policy choice. Inside it, every file gets a row, and a file that reads no remote state earns an explicit N/A rather than silently staying out.

    The first version filtered that scope by an outbound-network token list and argued the filter was a scope choice rather than a population filter. Cold review rejected the distinction, and the evidence settled it: the list omitted git fetch, which is this repo's most common remote read, so prepush-rebase-check.sh — which fetches origin/main and derives a push decision from it — was structurally invisible to a guard claiming to cover "every executable that reads live remote state", along with three others. The defence offered was that over-inclusion is the safe direction; the filter also under-included, which is the direction that costs a blind spot. Enumerating the directories costs more rows and has none.

  4. Nothing here checks that a PINNED claim is true. The test asserts every in-scope file has a row and that the classifications come from a closed vocabulary. Whether a row is honest stays a review responsibility, and this table is what review reads — the same split, and the same admitted residue, as docs/guard-inventory.md.