Release review of LH015
kind: review date: 2026-09-29 reviewer: a Lighthouse review subagent in Claude Code, model Fable 5.1 (claude-fable-5-1, as its harness reports) study: studies/LH015 (brief.md, amendments.md, record LH015.md 0.1) status: stage A done; stage B, the evidence review of the record and its piece, follows by message files: studies/LH015/review/r0021_blind.csv (the blind classes), r0021_blind_inputs.py (the input dump used to class), r0021_blind_reads.csv (the one clone and its reads), r0021_blind_compare.py and r0021_blind_compare.txt (the comparison, as code and output)
Stage A: blind check of the classification
What was read, in order
AGENTS.md (Rules that matter, Evidence, Writing); studies/LH015/brief.md in full; amendments.md (six, all applied); sources.md where a rule's meaning was in doubt (D4, D7, D14, D17). Then data/review_sample.csv, 60 builds (30 script-decided, 30 judged; 28 at snapshots, 32 at heads), and for those builds their rows in data/recipe_lines.csv, aux_lines.csv, tree_paths.csv, recipes.csv and frame_files.csv; pypi.csv was not needed (no class 3 arose). scripts/collect.py was read for one fact: it stores every line of a requirements file or lock that names one of the repository's libraries, so the absence of a stored line naming L is evidence that the file does not name L (checked on genaicomps, where seven other stored files at the same commit do carry cryptography lines). Until r0021_blind.csv was written, none of recipe_classes.csv, judgements.csv, pair_classes.csv, the measure tables, LH015.md or classify.py was opened.
One case the stored inputs could not decide: edumfa at 2d58e085c6c6 (P132-s1-0) builds a wheel from a manifest whose text is not stored. The repository was cloned anonymously (blobless, no checkout) at 16:54 UTC, pyproject.toml, setup.py and requirements.txt were read at that commit with git show, logged in r0021_blind_reads.csv, and the clone was deleted.
Agreement
Observation from r0021_blind_compare.txt: 57 of 60 builds agree by class. Script-decided: 27 of 30. Judged: 30 of 30.
By the writer's resulting pair class of each build's pair at its role: from the pinned file 9 of 9 at snapshots and 7 of 7 at heads; not from the pinned file 5 of 5 and 11 of 11; undetermined 11 of 11 and 11 of 11; nothing of the project's requirements 1 of 3 at snapshots and 2 of 3 at heads. Every disagreement sits in a pair the writer classed "nothing of the project's requirements".
Sub-labels and reasons differ without a class difference in a few builds, and agreement is counted by class: the four genaicomps builds (P307) carry "lock unused" from the script and "other file" from me, and both apply (the T files are compiled requirements files in the context that no step reads, and the file installed is another requirements file not in T); P248-h1-0 carries "external" from the writer and "would not build" from me, both class 6 (the recipe copies a lock and a wheel that a workflow job places and that do not exist at the commit).
The three disagreements and their resolution
-
P132-s1-0, edumfa, snapshot, L mako, T requirements.txt and uv.lock. Script: class 5, other named packages only. Reviewer: class 2, lock unused. The builder stage runs COPY . . and python -m build, and the final stage copies /tmp/dist/.whl from it and runs pip install /dist/.whl. The brief counts "a wheel built from it in the build" among the project's Python requirements, and its multi-stage rule says a wheel copied from a stage in the path carries that stage's class. The wheel is built from pyproject.toml, which is not in T; read at the commit, it carries static exact pins and does not name mako (mako arrives through alembic), and setup.py only ships requirements.txt as a data file, so amendment 1 does not apply. requirements.txt and uv.lock, both in T, are in the context through COPY . . and no step reads them. Resolution: the reviewer is right; the build installs the project's requirements and takes nothing from T, class 2 (lock unused). The rule at fault: classify.py records a wheel that resolves to another stage as a neutral effect ("wheel from a stage or step in the build") instead of the project install of the stage that built it; the same-stage case goes to its project rule. The pair's class at the snapshot changes from "nothing of the project's requirements" to "not from the pinned file". The same rule gives the same class to P132-h1-0 at the head; the 13 other builds in the record with that effect are class 1 or 2 on other steps, so the rule's whole effect is edumfa's pair, at snapshot and head.
-
P159-s1-0 and P162-h1-0, galaxy, .k8s_ci.Dockerfile at snapshot 2b06d179d95c and head a396860c90a6, L mako and cryptography. Script: class 5, other named packages only (pip install virtualenv ansible==11.11.0). Reviewer: class 6, script not read. The recipe clones galaxy-docker-k8s at build time and runs ansible-playbook on its playbook with galaxy_virtualenv_command=virtualenv, then deletes parts of $SERVER_DIR/.venv and sets the final stage's PATH to $SERVER_DIR/.venv/bin: the server's Python environment is built by a file outside the repository. Class 6's reason "script not read" names an install "in a file outside the repository", and the brief's missing-evidence rule says unknown is never counted as a negative; class 5 would state that nothing of the project's requirements is installed, which the recipe's own lines contradict. Resolution: the reviewer is right, with the ambiguity recorded below, since ansible-playbook is not in the brief's list of installers and the script has no rule for it. Both pairs change from "nothing of the project's requirements" to "undetermined". All 12 builds in the record that run ansible-playbook are this one recipe at the snapshots and heads of galaxy's six pairs (P158 to P163), all class 5 by the script, so the rule's whole effect is those six pairs at snapshot and head.
Thresholds
Judged: 0 of 30 wrong, so no re-judging is required and r0021_rejudged.csv was not made. Script-decided: 3 of 30 wrong, each in a way that changes its pair's class. The brief's threshold is more than 3, so it does not require the script to be corrected and every build classed again. The three errors come from two rules whose whole effect, traced through the stored effects and lines, is seven pairs at the snapshot (edumfa, nothing to not from the pinned file; galaxy's six, nothing to undetermined) and the same seven at the head. P1 is untouched. P2's denominator would gain one pair (edumfa, lockfile class, not from the pinned file), and the described counts of "nothing of the project's requirements" and "undetermined" pairs would move by six and by one. Whether to correct the two rules and reclass anyway, or to carry the seven pairs as a known limitation with a sensitivity, is the driving session's decision; stage B will check the record against it. Two of the three errors are one recipe at two commits, so by recipe the script erred twice.
Ambiguities found in the rules
-
Delegated installs. The install-step definition names installers and "a script, Makefile target or task-runner task in the repository"; class 6's "script not read" names "a file outside the repository". A RUN of ansible-playbook on a playbook fetched in the build falls between them. An amendment could say that a tool outside the list acting on the project from a file outside the repository is class 6 (script not read), which is how both galaxy builds are resolved here.
-
Build context from a bare "." or an unmatched wildcard. Rule (2) takes the recipe's own directory when every COPY source exists there; "." exists everywhere, so benchmark/Dockerfile (P020-h1-0, judged) gets benchmark/ as context and is class 6 (would not build) by the letter, though from the root it would be class 1 by amendment 1. A wildcard that matches nothing (P079-h4-0, COPY pyproject.toml uv.lock*, script-decided) leaves rule (2) with no context, and rule (3) gives class 6 (context), though Docker accepts an unmatched wildcard beside a matched source and the root is the only directory where the COPY succeeds (class 1 there). Writer and reviewer applied the letter in both and agree; the record should call these "undetermined by rule", not builds that fail in fact.
-
Sub-labels are not exclusive (lock unused and other file both fit P307), so the check counts agreement by class; the brief's sub-label order should be stated if the sub-labels are reported.
-
Installs at start from a run-time mount (weblate, P450-h2-0 and P448-h2-0): the only install runs in the ENTRYPOINT inside a directory the build leaves empty and a volume fills. No rule covers it; both readings are class 6 (other).
-
Head builds that install from a lock absent at the snapshot (P185-h3-0, agentstack): S2's "checked against LH008's rules for L" was read by both as class 4 (another pinned file). The record should state that reading, since S2's head table depends on it.
Gaps in the stored record
data/aux_lines.csv holds ignore files, scripts, manifests and requirement lines, but no workflow lines and no compose stanzas, though the brief's classification step 1 lists both. Contexts fixed by a workflow or a compose file (context_how in recipes.csv) rest on reads the read log shows were made (for instance .github/workflows/docker-release.yml at aider's snapshots) but whose lines were not kept, so a reviewer cannot re-derive them from the record. This is load-bearing for the twelve aider docker/Dockerfile builds in the sample, where rule (2) alone would give docker/ as the context and class 6; the stored root context was accepted as an input. Retaining those lines, or saying in Limits that they were read and not kept, would close the gap.
Stage B: evidence review
Done the same day, on the driving session's message, after the record went to version 0.2 (commit c488d2a) and the piece, short form, chart and surfaces were drafted. Files added in this stage, all under studies/LH015/review/: r0021_corrections.py and r0021_corrections.txt (what moved between 0.1 and 0.2, and the frame facts the piece rests on), r0021_measures.py and r0021_measures.txt (the reviewer's own reproduction of the figures), r0021_live_reads.csv (every live read of this stage, with its UTC time).
What was checked
Replay. studies/LH015/replay.sh was rerun: the brief's hash holds, 30 of 30 classifier tests pass, all 651 rule (1) contexts are supported by the retained compose and workflow lines, the ceilings hold (442 clone attempts, 5,671 blob reads, 24 PyPI requests, 3.10 GB of clones in all), and every output, the figure included, is byte for byte identical.
Reproduction, with code independent of analyse.py (r0021_measures.txt, from data/pair_classes.csv, recipe_classes.csv and frame.csv). Every figure agrees with the record: P1 247 of 469, 52.7 per cent, repository view 45.3 per cent (151 repositories at any snapshot, 150 at every one); P2 129 of 167, 77.2 per cent, bounds 83.8 and 54.9; lockfile 21 of 37 (56.8; bounds 79.5 and 26.9), exact pin 63 of 77 (81.8; 83.9 and 72.4), both 45 of 53 (84.9; 88.6 and 64.3); undetermined 68 of 247, 27.5 per cent; repository view of P2 70.2 per cent; main recipe 146 of 173, 84.4 per cent; the chart's twenty counts by pin class; behind "not from the pinned file" class 2 in 24 pairs (lock unused 7, other file 15, lock absent 1, upgrade 1), class 3 in 14, class 4 in 5, by pin class 16, 14 and 8; R3 12 of 38 and P2 without them 129 of 155, 83.2 per cent; the scale bounds 38 (8.1 per cent) and 106 (22.6 per cent) of 469; 45 of 90 decided pairs with a timed lockfile have a build that reads it; snapshot builds 614 by the script and 117 judged. A bootstrap with a different seed gives P1 45.6 to 59.1 and P2 68.4 to 84.5 against the record's 45.9 to 59.3 and 68.6 to 84.4, so the intervals are the size the method gives. S1: my reading of the stored specifiers differs on three pairs (P084 and P141, where one part of the specifier is unreadable and the writer calls the pair not determined where I would call it "excludes or unreadable"; P383, an upgrade with no specifier, which the writer reads as admitting the fix), so the record's 2, 12 and 24 follow a stricter and defensible rule; the record's wording "not determined" is right for them.
The corrections of 0.2 (r0021_corrections.txt). 18 builds changed class between 2eb774f and c488d2a, 9 at snapshots: edumfa's two (5 to 2, lock unused, now judged), galaxy's twelve (5 to 6, amendment 7), agentcrew's two and vantage6's head build (2 to 4, amendment 2 and the brief's class 4 rule), and secure-string-cipher's snapshot build (2 to 1, the wheel rule). No sub-label or flag changed with the class unchanged. Eight pairs changed class at the snapshot and seven at the head, as the Corrections entry says; the review sample is unchanged; three builds moved between the script's and the writer's pools, as the Method says. These are exactly the stage A errors, their rule-mates and the amendment 2 cases, and nothing else moved.
Live verification (r0021_live_reads.csv; three anonymous blobless clones, deleted after reading). bbot at d57a125e5286: the commit resolves with the committer time the frame records; the Dockerfile and Dockerfile.full run COPY . . then pip install ., there is no .dockerignore, poetry.lock names pyjwt 2.11.0, and pyproject.toml says pyjwt = "^2.7.0"; the frame's lag for that pair is 47.754 days, so "another 48 days" holds. PyPI's JSON: pyjwt 2.13.0's first upload 2026-05-21T19:54:35Z. OSV's JSON: GHSA-xgmm-8j9v-c9wx published 2026-06-15T19:28:06Z, fixed 2.13.0, which is the frame's advisory time to the second; but OSV also lists PYSEC-2026-175 to 179 published 2026-05-28T16:16Z, and LH008's record notes those CVE and PYSEC notices seven days after the release, so the earliest public notice of the flaws was 28 May, not 15 June (LH008's brief fixed the GHSA record as its clock). Two corrected builds: agentcrew at aba034c13d61 copies docker/pyproject.docker.toml over pyproject.toml and installs from it, and that file pins mcp==1.27.1, so amendment 2 applies as the record says; secure-string-cipher at 44126f19fada builds a wheel from a pyproject.toml pinning cryptography==46.0.3 and installs it in the final stage, class 1 as the record says. With stage A's edumfa read, five builds were verified at their commits.
The chart, rendered from the SVG in light and dark at 720 by 374: the twenty counts read correctly, the hatched undetermined band and the outlined "no recipe" band are legible in both themes, and the three narrowest segments (12, 5 and 1) are unlabelled and carried by the alt text. articles/recipes-follow-the-pin.svg is byte for byte the record's recipes.svg. Its footer names the record's file ("Source: studies/LH015, data/pair_classes.csv"); it is drawn by the record's script and compared by the replay, so I did not touch it (see proposals).
Titles, summaries, notices and surfaces. No absolute in the titles or summaries; the window, April 2025 to August 2026, is the frame's release times (2025-04-10 to 2026-08-13) and is given in the piece's summary, the short form, the synthesis notice, README's current focus and observations.md; 31 libraries, 332 projects and 469 moments hold. Every number in the piece, the short form, the two notices, README.md's current focus, AGENTS.md's "Where things stand", programme.md's LH015 entry and Next paragraph, investigations/software-updates.md and observations.md's row matches the record at 0.2, and the surfaces say the same thing at the same certainty: about half kept a recipe, 129 of 167 where the rules could decide, more than a quarter undecided, no label, a recipe is not a build. British English throughout, no em or en dashes in any of them, and no internal reference in the pieces' prose ("a second reviewer" in the piece describes the method, not a document; the colophons name the record as they should). The short form runs to about 240 words without its title.
What was found and changed
- The piece's advisory date (must fix, fixed). "which fixed a security flaw; a public advisory followed on 15 June" gave the frame's clock as if it were the first warning; the release fixed several flaws and public notices of them appeared on 28 May. Now: "which fixed security flaws; public notices of the flaws followed, the first on 28 May and GitHub's advisory naming the fix on 15 June". OSV's current record does not show whether the 28 May notices named the fix on that day, so the sentence attributes the naming to the GitHub advisory only.
- "7 of the 167" against "only half" (must fix, fixed). The piece said "an image built beside its lockfile without reading it ... here it was uncommon: 7 of the 167 we could decide", and two paragraphs later that a build read the lockfile in only 45 of the 90 decided cases where one held the older version; the two read as a contradiction. The 7 are the class 2 pairs that leave the lock unread and resolve afresh; of the 45 decided pairs without a lock-reading build, 26 are "both" pairs from the pinned file through an exact pin elsewhere, 14 lockfile pairs and 5 "both" pairs not from it. The sentence now names the pattern and adds the 26; the synthesis notice's matching phrase was sharpened the same way; and Finding 5 of the record now states the 26, 14 and 5 with the reviewer's code as its source, so the piece's figure traces.
- S1's wording (should fix, fixed). "for 24 the instructions do not say" overstated the rule: bbot's pyproject.toml says
^2.7.0, which admits 2.13.0 by Poetry's reading, but the brief's PEP 440 rule cannot read it. Now "for 24 we could not read an answer from them". - The colophon's read window (should fix, fixed). Sources named 15:57 to 16:28 UTC only; version 0.2 read 110 repositories again at 17:09 UTC for their compose and workflow lines. Added.
- Record, Finding 5 (fixed): the breakdown of the 45 decided pairs without a lock-reading build, with its source.
Nothing else in the record, the short form or the surfaces needed a change. The [REVIEWS] placeholders are the release's to fill.
Proposals, not made
- The chart's footer names a record path inside the illustration; a reader-facing line ("Source: the study record") would match the prose rule, but the figure is drawn by studies/LH015/scripts/draw_figure.py and checked byte for byte by the replay, so the change belongs to the script and the replay together.
- A future brief in this line should fix how an unmatched wildcard source and a bare
.source fix the build context (stage A, ambiguity 2), which the record's Findings 6 and Next already describe. - LH008's advisory clock is the GHSA record; where a piece opens with a named advisory, it should say which notice it means, as this one now does.
Verdict
Nothing that must be fixed remains: the two items that had to be fixed (the advisory date and the lockfile-unread sentence) and three smaller ones were fixed in this stage, in the piece, the synthesis notice and the record's Finding 5, and are listed above. Reviewed by a Fable 5.1 agent on 29 September 2026; a second agent's agreement is a review contribution, not independent confirmation.
From notes/R-0021.md in the repository, last changed 29 September 2026.