Skip to content

fix(parser): reconcile docker reference splitting and skip npm subcommands (narrows #180) - #184

Merged
ViperJuice merged 6 commits into
mainfrom
fix/180-package-identity-parsing
Aug 25, 2026
Merged

ViperJuice merged 6 commits into
mainfrom
fix/180-package-identity-parsing

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator

Narrows #180. Deliberately does not close it — see What this does not fix.

2.4.0's identity gate decides whether a cached description still describes the configured package by comparing the name detect_package_type returns. A name that is stable across two different packages is therefore not a cosmetic parsing wart: _same_package reads it as a positive confirmation, and the freshness short-circuit goes on serving the wrong package's tool descriptions.

Two causes, both closed

Docker split on the first : — so registry:5000/old-image and registry:5000/new-image both resolved to registry, the registry host rather than an image. A colon only introduces a tag in the final path segment; before the last / it is a host:port.

The correct rule already existed in the same module as _docker_image_tag, which is what pin detection uses. So the fix adds its paired complement _docker_image_name — same design as _strip_npm_tag/_npm_tag — rather than a second, divergent implementation. My first draft did write a second rule, and the board caught that it both regressed img:1.2@sha256:abc (→ img:1.2@sha256, where today's code correctly gives img) and disagreed with _docker_image_tag on that same input.

npm subcommands were taken as the package — npm exec old-pkg and npm exec new-pkg both resolved to exec. A leading subcommand is now skipped once, and for npm only:

  • once, not repeatedly, because i and exec are real npm packages — a docker-style anywhere-loop turns npm install i and npm exec exec into None
  • npm only, because npx -y exec genuinely names a package called exec
  • x is included; without it npm x collides exactly as npm exec did

_npm_package_arg gains a required command parameter, not a defaulted one: a default would let one of the two shared call sites keep old behaviour silently while type-checking clean — the exact drift the shared scan exists to prevent.

Two live defects found along the way

Neither is in #180; both fell out of the same shared-scan bugs.

  • A docker digest was reported as a version pin. img@sha256:abc reported a pin of abc — a fragment of the digest — and img:1.2@sha256:abc reported 1.2@sha256:abc rather than 1.2.
  • A pin on an npm exec server was reported as unpinned. npm exec pkg@1.2 scanned to exec, which carries no version suffix, so a real pin was missed.

What this does not fix

My plan asserted that docker's image-token selection was sound and only tag-stripping was broken. The board falsified that. Identity is still collapsed wherever a flag's value is taken as the package name — and these are ordinary forms:

docker run --env-file .env old-image / new-image     -> both ('docker', '.env')
docker run --mount type=bind,src=/a A / B            -> both ('docker', 'type=bind,...')
npm exec --package=old-pkg / new-pkg -- shared-bin   -> both ('npm', 'exec')

Tracked on #182 (broadened for this), with #183 for the more serious consequence: update_server builds npx -y {parsed}@latest --help, and npx -y installs without prompting, so a misparsed npm run <script> installs and executes a registry package.

Verification

2717 passed, 3 skipped, 0 failed; ruff clean; mypy clean (42 files).

Six mutants, each run against one test node alone — never the class. A class-level run reddens on any member, so it cannot show the specific test is load-bearing; that is the hole two hollow tests shipped through in this repo's last two phases.

mutant kills
docker → raw.split(":")[0] the docker collision test
skip_subcommand = False both npm collision tests
skip fires repeatedly both real-package-name tests
digest not stripped first only the 2 digest cases, 8 others green
pin site → _npm_package_arg(args, "npx") the pin matrix
_docker_image_tag stops stripping digest the pin matrix

The last two are what pin the helpers as complements: each half has its own mutant, so a divergence cannot pass silently.

Collision tests assert inequality of real pairs rather than single expected values — a value assertion passes against a parser that collapses both forms onto some other shared name.

Migration

Affected servers refresh once: a docker server on a host:port registry or an npm exec server now has a different package identity than its cache recorded, so that entry fails the identity check once and regenerates. Same one-time migration 2.4.0's package_type addition caused.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

ViperJuice and others added 6 commits August 25, 2026 20:11
…sions

Closes-plan-for: #180

Planning artifact only; no source changes. Under board review at time of
commit, so the plan may be revised before execution.

Research found two root causes rather than one, and a third collision the
issue does not record:

  docker run registry:5000/old-image  -> ('docker', 'registry')
  docker run registry:5000/new-image  -> ('docker', 'registry')
  docker run localhost:5000/a/b:tag   -> ('docker', 'localhost')
  npm exec old-pkg                    -> ('npm', 'exec')
  npm exec new-pkg                    -> ('npm', 'exec')

Docker splits on the FIRST colon, but an OCI reference only carries a tag
when the colon follows the last slash -- before that it is a registry
host:port. npm has no subcommand skip at all, which is an asymmetry:
_docker_image_arg already skips its own subcommands at version_checker.py:365.

The plan's flagged hazard is _npm_package_arg. It is deliberately SHARED
between detect_package_type and update_server's pin detection
(handlers.py:307), with a docstring stating the two must never disagree about
which argument is "the package", so a signature change must update both call
sites in one commit.

Scope deliberately excludes uvx --from (already correct) and the pip/cargo
branches (no measured collision).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Correctness seat returned PARTIALLY AGREE with three must-fixes. All verified
against source before acting; all three hold.

The docker rule I specified was wrong, and the right one already exists.
"Split on the last colon when it follows the last slash" regresses
img:1.2@sha256:abc from img (correct today) to img:1.2@sha256, because that
last colon belongs to the digest. Worse, it would have been a SECOND home for
a rule _docker_image_tag (version_checker.py:377) already implements
correctly -- last segment, first colon -- and which update_server's pin
detection already uses. The two diverge on the same input: _docker_image_tag
gives 1.2@sha256:abc, my rule gave abc. The plan now adds
_docker_image_name as _docker_image_tag's paired complement, strips @digest
first, and requires paired assertions so they cannot drift.

npm skip semantics were ambiguous. The draft said both "leading" and "the
same way the docker path does"; those contradict, and docker skips
subcommands ANYWHERE, which would swallow real packages -- npm install i
(a genuine package) and npm exec exec both resolve to None under that
reading. Now specified skip-once, leading-only, with pinning tests.

The pin-detection call site is a behaviour FIX, not a mechanical update. The
draft claimed no behavioural change; in fact npm exec pkg@1.2 currently
pin-detects on "exec", so _npm_tag returns None and a real pin is MISSED.
After the change it is found. Needs its own RED test and a CHANGELOG line.

Scope claim corrected and a new issue filed. The draft asserted the pip and
cargo branches "are not implicated" and uvx was fine. False: every excluded
branch collides through unskipped value-carrying flag values --
uvx --python 3.12 A/B, uvx --with requests A/B, pip --index-url URL A/B,
cargo --features full A/B all collapse to one identity. uvx --from is
correct only by luck. Filed as #182 rather than folded in;
uvx is arguably the most realistic trigger of the whole class.

Also: localhost:5000/a/b:tag was filed under "must not move" when it is a FIX
case, which an executor reading literally could have enshrined. And the
mutation proofs must now have their output recorded in the handoff, not
merely performed -- both prior hollow tests were caught only by a board, and
one was written by the agent that had just documented the failure mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…mands

Narrows #180. Does NOT close it -- see below.

Docker: detect_package_type took the FIRST colon (raw.split(":")[0]), so
registry:5000/img read as image "registry" and two different images on one
host:port registry confirmed as the same package under 2.4.0's identity gate.
The correct rule already existed in this file as _docker_image_tag, so the
fix adds its paired complement _docker_image_name rather than a second,
divergent rule -- the same design as _strip_npm_tag/_npm_tag.

Digest handling is why the pairing matters. An earlier draft split on the
LAST colon after the last slash, which regressed img:1.2@sha256:abc from img
to img:1.2@sha256, because that colon belongs to the digest. Both helpers now
strip @digest first, then split the last path segment on its first colon.

That also fixed a live defect in _docker_image_tag: img@sha256:abc reported a
tag of "abc", so update_server's pin detection reported a DIGEST FRAGMENT as
a version pin, and img:1.2@sha256:abc reported 1.2@sha256:abc instead of 1.2.

npm: _npm_package_arg had no subcommand skip, so npm exec old-pkg resolved to
"exec" -- identical for every package. It now skips ONE leading subcommand,
for npm only. Once, not repeatedly, because i and exec are real package names
and a docker-style anywhere-loop turns npm install i and npm exec exec into
None. npm only, because npx -y exec genuinely names a package called exec.
The x alias is included; without it npm x collides exactly as npm exec did.

command is now a REQUIRED parameter, not defaulted: a default would let one
of the two shared call sites keep old behaviour silently while type-checking
clean, which is the drift this shared scan exists to prevent. The pin-
detection call site is updated in the same commit, and that is a behaviour
fix in its own right -- npm exec pkg@1.2 scanned to "exec", whose _npm_tag is
None, so a real pin was reported as unpinned.

Board found two things this does NOT fix, both filed: #182 (docker's
_value_flags table is incomplete, so --env-file/--mount still collide, and
npm exec --package= is discarded) and #183 (update_server builds
npx -y <parsed>@latest --help, so a misparsed npm run <script> installs and
executes a registry package).

Tests and CHANGELOG follow in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…oofs

Tests and CHANGELOG for the parser fix in a906c85.

Three new classes in test_version_checker.py. TestPackageIdentityCollisions
asserts INEQUALITY of real pairs rather than single expected values -- a value
assertion passes against a parser that collapses both forms onto some other
shared name, so only the inequality is falsified by the collision itself.
TestDockerReferenceSplitting parametrises name/tag together so the two helpers
cannot drift apart. TestNpmSubcommandSkipFiresOnce pins that npm install i and
npm exec exec still find their real packages and npx never skips.

The pin matrix in test_tools.py gains the docker digest forms and the npm
exec/x pin cases.

Six mutants, each run against ONE test node alone, never the class -- a
class-level run reddens on any member and so cannot show the specific test is
load-bearing, which is the hole two hollow tests shipped through in the last
two phases. Evidence recorded in the handoff:

  docker branch -> raw.split(":")[0]        kills the docker collision test
  skip_subcommand = False                   kills both npm collision tests
  skip fires repeatedly                     kills both real-package-name tests
  digest not stripped first                 kills ONLY the 2 digest cases
  pin call site -> _npm_package_arg(args,"npx")  kills the pin matrix
  _docker_image_tag stops stripping digest       kills the pin matrix

The last two are what pin the helpers as complements: each half has its own
mutant, so a divergence cannot pass silently.

CHANGELOG says plainly that this NARROWS #180 rather than closing it, names
the forms that still collide, and records the one-time refresh affected
servers will do -- the same migration note 2.4.0 used for package_type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Cosmetic line-joining in TestPackageIdentityCollisions; no assertion changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…g mutants

Second board round on the implementation. grok DISAGREE, codex DISAGREE,
gemini AGREE, fable PARTIALLY AGREE. Every finding verified against source
before acting; all hold.

REGRESSION THIS PR INTRODUCED, caught by the red-team seat and confirmed by
the correctness seat. Stripping @digest before the tag read was right for the
NAME but broke the PIN: a digest-only reference then reported None, which
reads as UNPINNED. Before this PR the same reference produced the garbage pin
'abc' -- wrong, but truthy, so update_server's refusal still fired. I turned
wrong-but-safe into right-looking-but-unsafe: update_server would have pulled
image:latest, restarted the unchanged digest-pinned config, and recorded the
registry's newest digest while still running the old immutable image.

Fixed with _docker_image_digest, the third member of the reference-splitting
family, consulted BEFORE the tag. A digest is the tightest pin docker offers.
img:latest@sha256:abc is the same trap wearing a tag the == "latest" test
would otherwise discard; all eight docker pin forms now verified.

SURVIVING MUTANT A (grok and fable, independently): swapping the flag skip
and the subcommand check passed all 2717 tests. The code was already correct
-- npm --silent exec old-pkg resolves to old-pkg -- but nothing PINNED the
ordering, because every npm test put the subcommand first. Correct by
accident is not correct by contract. Pinned now.

SURVIVING MUTANT B (fable, unique): the guard I wrote against a hardcoded
call site was itself HOLLOW. detect("npm","npx",["-y","exec@1.2"]) == "1.2"
cannot enforce "npx must not skip a package named exec", because the skip
matches the RAW token against _NPM_SUBCOMMANDS and exec@1.2 is not in the
set -- so the mutant returns the identical 1.2. The bare exec token
discriminates: correct gives None, mutant gives 1.2. That is the third hollow
test in three phases, and the second written by the agent that had just
documented the failure mode.

TOOLING: stale bytecode fabricated a false RED in the full-suite run (a
pure-reorder mutant has identical size, and apply/compile/restore inside one
second defeats pyc invalidation). The same mechanism can fabricate a false
GREEN, i.e. certify an unkilled mutant. All nine proofs re-confirmed with
__pycache__ purged and PYTHONDONTWRITEBYTECODE=1, and mutation-180.md now
requires it.

Mutation evidence moved into the repo at .consiliency/evidence/mutation-180.md
-- the plan required the output recorded, and scratch is not the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
@ViperJuice

Copy link
Copy Markdown
Collaborator Author

Second board round on the implementation — all four seats, four findings, all closed in 360f226

Seat Verdict
grok-4.6 (adversarial) DISAGREE — surviving mutant A
gpt-5.6-sol (red-team) DISAGREE — digest-pin regression
gemini-3.7-flash (alternative-approach) AGREE
claude-fable-5 (correctness) PARTIALLY AGREE — confirmed A, found B

1. A regression this PR introduced (red-team, confirmed by correctness)

Stripping @digest before the tag read was correct for the name and broke the pin:

BEFORE  img@sha256:abc  -> pin 'abc'   (garbage, but truthy → refusal fired)
AFTER   img@sha256:abc  -> pin None    (reads as UNPINNED)

A digest is the tightest pin docker offers. With None, update_server would pull image:latest, restart the unchanged digest-pinned configuration, and record the registry's newest digest — announcing an update while still running the old immutable image. I converted wrong-but-safe into right-looking-but-unsafe, which is strictly worse than the bug I was fixing.

Fixed with _docker_image_digest, the third member of the reference-splitting family, consulted before the tag. img:latest@sha256:… is the same trap wearing a tag that the == "latest" test would otherwise discard. All eight docker pin forms verified.

2. Surviving mutant A (grok and fable, independently)

Swapping the flag-skip and subcommand checks passed all 2717 tests. The code was already correct — npm --silent exec old-pkg → old-pkg — but nothing pinned the ordering, because every npm test put the subcommand first. Correct by accident is not correct by contract. Now pinned; the mutant dies.

3. Surviving mutant B (fable, unique) — the guard I wrote was itself hollow

detect("npm","npx",["-y","exec@1.2"]) == "1.2", commented "npx still must not skip a package genuinely named exec", cannot enforce that. The skip matches the raw token against _NPM_SUBCOMMANDS, and exec@1.2 is not in the set — so a call site hardcoded to "npm" returns the identical 1.2:

correct : npx -y exec@1.2     -> '1.2'
mutant  : npx -y exec@1.2     -> '1.2'    <- no divergence, guard useless
correct : npx -y exec pkg@1.2 -> None
mutant  : npx -y exec pkg@1.2 -> '1.2'    <- divergence

That is the third hollow test in three phases, and the second written by the agent that had just documented the failure mode. Reading an assertion cannot distinguish a hollow one from a real one; only mutating the exact line it claims to pin can.

4. Tooling: stale bytecode fabricates false red and false green

A full-suite run reported mutant A's new test as FAILING against correct source. Cause: a pure-reorder mutant has identical byte size, and apply/compile/restore inside one second defeats .pyc invalidation (whole-second mtime + size). Purging src/**/__pycache__ → 5 passed.

The same mechanism can fabricate a false green — certifying a mutant as killed when it was not. All nine proofs re-confirmed with __pycache__ purged and PYTHONDONTWRITEBYTECODE=1, and .consiliency/evidence/mutation-180.md now requires it for any future proof in this repo.

Evidence location

Moved from scratch into the repo at .consiliency/evidence/mutation-180.md. The plan required the mutation output recorded; scratch is not the repo, so that acceptance criterion was genuinely unmet until now (red-team finding 2).

Verification

2718 passed, 3 skipped, 0 failed with pycache purged and bytecode disabled; ruff clean; mypy clean. CI green on all ten checks.

@ViperJuice
ViperJuice merged commit 5043d11 into main Aug 25, 2026
11 checks passed
ViperJuice added a commit that referenced this pull request Aug 26, 2026
…review

grok PARTIALLY AGREE, codex DISAGREE, gemini AGREE. Two seats independently
found the same blocking defect and both were verified against the workflow.

workflow_dispatch as specified was not merely a security widening -- it was
INCORRECT. actions/checkout at release.yml:12 and :63 pins no ref, and both
tag derivations read GITHUB_REF_NAME (:73, :96). Under workflow_dispatch that
is the ref chosen in the "Use workflow from" dropdown, not a workflow input.
So dispatching tag=v2.4.1 from main would check out main, publish MAIN's
artifacts, and try to create a release named "main" -- the required input
silently ignored. Writing an input and never wiring it is the same class of
defect as a hollow test: it looks like it constrains something.

grok found the worse half: PyPI publish can SUCCEED while the GitHub release
step fails, leaving a half-published version. Both mitigations I offered fail
on inspection -- trusted publishing scoped to repo+workflow does not restrict
to tag events, and environment: release only gates if that environment has
required reviewers, which the plan never verified.

Recovery already exists and is what unblocked v2.4.1: gh run rerun on the
failed tag-push run. The timeouts are themselves the recovery improvement --
a 6-hour silent hang becomes a ~20-minute red X.

Also fixed my own fail-open verification command. `command -v actionlint &&
actionlint ... || echo skipped` swallows a non-zero actionlint into the ||
branch and exits 0, so any finding prints "skipped" -- the same shape as the
hollow assertions caught in #184 and #185. Now an explicit if/else where a
non-zero actionlint fails.

Both seats confirmed the timeout table: 4x with a 10-minute floor is right,
and grok noted tightening the floor to 5 would be the actual false-kill risk
for min-version-smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
ViperJuice added a commit that referenced this pull request Aug 26, 2026
* chore(plan): detailed plan for CI job timeouts

Closes-plan-for: #187

Planning artifact only; no workflow changes. Under board review at time of
commit.

Every timeout value is MEASURED, not estimated -- per-job durations pulled
from the GitHub jobs API across the last five runs of each workflow, sized at
roughly 4x observed p100 with a 10-minute floor. release.yml build is 4m10s
observed and gets 20; the test matrix is 4-5m and gets 20; everything else is
sub-minute and gets 10. pipeline-bootstrap.yml already sets 30 and is left
alone.

The plan flags workflow_dispatch on release.yml as the part I am least sure
of: it makes a stalled release recoverable without rewriting a published tag,
but it also means anyone with write access can trigger a PyPI publish for an
arbitrary ref. Written so the board can strike it and keep the timeouts, which
are the actual fix for #187.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb

* chore(plan): strike workflow_dispatch from the #187 plan after board review

grok PARTIALLY AGREE, codex DISAGREE, gemini AGREE. Two seats independently
found the same blocking defect and both were verified against the workflow.

workflow_dispatch as specified was not merely a security widening -- it was
INCORRECT. actions/checkout at release.yml:12 and :63 pins no ref, and both
tag derivations read GITHUB_REF_NAME (:73, :96). Under workflow_dispatch that
is the ref chosen in the "Use workflow from" dropdown, not a workflow input.
So dispatching tag=v2.4.1 from main would check out main, publish MAIN's
artifacts, and try to create a release named "main" -- the required input
silently ignored. Writing an input and never wiring it is the same class of
defect as a hollow test: it looks like it constrains something.

grok found the worse half: PyPI publish can SUCCEED while the GitHub release
step fails, leaving a half-published version. Both mitigations I offered fail
on inspection -- trusted publishing scoped to repo+workflow does not restrict
to tag events, and environment: release only gates if that environment has
required reviewers, which the plan never verified.

Recovery already exists and is what unblocked v2.4.1: gh run rerun on the
failed tag-push run. The timeouts are themselves the recovery improvement --
a 6-hour silent hang becomes a ~20-minute red X.

Also fixed my own fail-open verification command. `command -v actionlint &&
actionlint ... || echo skipped` swallows a non-zero actionlint into the ||
branch and exits 0, so any finding prints "skipped" -- the same shape as the
hollow assertions caught in #184 and #185. Now an explicit if/else where a
non-zero actionlint fails.

Both seats confirmed the timeout table: 4x with a 10-minute floor is right,
and grok noted tightening the floor to 5 would be the actual false-kill risk
for min-version-smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb

* ci: give every job a timeout-minutes (#187)

No job in release.yml, test.yml, docker.yml or maintenance.yml set
timeout-minutes, so a stalled job ran to GitHub's 6-hour default. On the
release path publish and github-release each needs: the job before them,
so a single stall silently blocked the whole publish — and because the
tag push *is* the publish, a version could sit tagged-but-unshipped with
nothing surfacing it. Cutting v2.4.1, build hung 3h11m on "Run tests with
coverage" against a healthy commit; cancel + gh run rerun cleared it.

Values are ~4x the measured p100 per job (from the Actions jobs API),
floored at 10 minutes: the point is to fail fast on a stall, not to
police normal variance. docker build gets 20 despite running under a
minute because image builds are network-bound and legitimately spiky.
test gets 25 rather than 20 because the live p100 for the 3.10 leg is
5.30m, and 4x that is 21.2 — a 20 would have falsified the >=4x rule on
day one.

  release.yml     build 20, publish 10, github-release 10
  test.yml        test 25, install-smoke 10, min-version-smoke 10,
                  lint 10, changelog 10, typecheck 10
  docker.yml      build 20
  maintenance.yml notify-worker 10

Job level, not step level, so the bound covers setup too; on the test
matrix it applies per leg. pipeline-bootstrap.yml already sets 30 and is
left untouched.

workflow_dispatch on release.yml was proposed as a recovery path and is
deliberately NOT added. Beyond widening who can publish, the specified
form was incorrect: checkout pins no ref and both tag derivations read
GITHUB_REF_NAME, so a dispatch with tag=v2.4.1 from main would build and
publish main's artifacts and name the release "main" — the input would be
silently ignored, and PyPI can succeed while the release step fails.
gh run rerun on the tag-push run is the existing recovery, and these
timeouts are the real improvement: a 6-hour silent hang becomes a
~25-minute red X.

CI-only, no src/ change, so no CHANGELOG.md entry per repo convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbUnAuFarzK5kRz92t8MW8

* fix(ci): raise the test-matrix timeout to 25 and harden the acceptance test

Follow-up to a694f87, applying the advisor board's findings on the plan.

VALUE CORRECTION. Live test(3.10) p100 is 5.30m measured over five runs, not
the 4-5m the plan's table recorded, so the plan's own acceptance criterion
("each value >= 4x observed") was falsified by its own value of 20 on day one.
Raised to 25. Every other value stands; two seats independently confirmed the
4x rule and the 10-minute floor, and noted that tightening the floor to 5
would be the real false-kill risk for min-version-smoke.

THREE ACCEPTANCE-TEST BYPASSES, found by the board's correctness seat and each
reproduced before being acted on. Recorded with before/after exit codes in
.consiliency/evidence/bypass-proofs-187.md:

  A  a .yaml workflow escapes a *.yml-only glob         exit 0 -> 1
  B  timeout-minutes: 360 passes a presence check --    exit 0 -> 1
     re-creating the very 6-hour default this fixes
  C  an over-indented job is swallowed into the
     previous job's mapping; jobs parses without it
     and the check passes anyway                        exit 0 -> 1

C is the dangerous one. release.yml triggers only on tag push, so it never
appears in PR checks -- the plan's claim that "the real proof is CI itself" is
false for exactly the file this change is about. A mangled release.yml would
have surfaced at the next tag push as a broken release. The hardened check
compares job-name sets against main and FAILS rather than printing.

The hardened check also validates the value (int, not bool, 1..30) rather than
its presence, globs both extensions, and skips `uses:` jobs since a
reusable-workflow call cannot carry timeout-minutes.

actionlint is now mandatory rather than best-effort: release.yml cannot be
exercised pre-merge, so it is the only Actions-schema check on the release
path before a tag is cut. The plan's original invocation was fail-open
(`&& actionlint || echo skipped` swallows a non-zero exit), the same defect
class as B.

Verified on this tree: all three bypasses exit 1, actionlint 1.7.12 exit 0,
job sets match main, pipeline-bootstrap.yml untouched at 30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb

* docs(evidence): correct the bypass matrix and record that check #2 stands alone

Implementation pass re-ran the full matrix against the committed state and
found three things the evidence file got wrong or missed. All re-verified here
before recording.

My instruction said all three bypasses must make CHECK #1 exit non-zero. That
is wrong for C: check #1 counts jobs that survive parsing, so a swallowed job
cannot make a presence-check fail. C is caught by check #2. The bypass is
genuinely closed, just by a different check than I claimed.

The finding worth acting on is c3. Deleting a terminal job OUTRIGHT yields a
perfectly valid workflow: check #1 exits 0, actionlint exits 0, and only the
job-set comparison against main exits 1. actionlint caught c and c2 only
because over-indentation is a syntax error; deletion is not. So for
release.yml -- tag-push only, never in PR checks -- the job-set comparison is
the SOLE guard between a dropped publish job and a silently broken release.
That is a stronger argument for its fail-rather-than-print form than this
document originally made.

Also: my actionlint invocation had the same extension gap as bypass A.
`actionlint .github/workflows/*.yml` never schema-checks a .yaml workflow --
verified, exit 0 against a sneaky.yaml with a bogus `with:` key, while bare
`actionlint` exits 3. I widened check #1 to both extensions and left the
actionlint line globbing. Use bare actionlint.

And the `uses:` exemption in check #1 is currently inert -- no job in the five
workflows is a reusable-workflow call, so it exempts nothing today.

No workflow file changed in this commit; evidence only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb

* docs: harden the plan's verification block and record check 0

Implementation board: grok AGREE, gemini AGREE, codex DISAGREE, fable
PARTIALLY AGREE. Two live findings, both closed here; the third (CI evidence)
was independently confirmed -- 11/11 green, check set identical to a main PR.

THE PLAN SHIPPED THE PROVEN-WEAK VERIFICATION. Both dissenting seats found it.
This PR's plan still carried check #1 as glob("*.yml") presence-only, check #2
as print-only, and actionlint with the extension gap -- the exact three
bypasses the adjacent evidence file proves are exploitable. It would have
shipped the weak script as the canonical procedure with the proof of its
weakness one file away. Replaced with the hardened forms, plus a note
forbidding reintroduction, and the stale table row (test -> 20, "4-5m")
corrected to 25 / 5.35m p100.

CHECK 0 IS STRONGER THAN 1-3 COMBINED. The correctness seat's contribution:
checks 1-3 guard only {job-name set} x {timeout presence and value}, so every
other byte of release.yml is unguarded. Seven mutants pass all three --
dropping `environment: release`, rewiring `needs:`, corrupting the tag pattern,
deleting the tags filter, and more. The tag-pattern case is #187's own
nightmare exactly: corrupt "v*" to "V*" and NOTHING runs, so there is never a
red X. Verified that check 0 -- every changed line matches
^\+\s+timeout-minutes: \d+$, nothing removed -- catches all three silent
release.yml mutants the others miss. It leads the block now.

FLOOR RAISED 1 -> 10. Red-team found that accepting 1..30 leaves build 20 -> 1
passing every check while killing a 4m10s job after a minute. Verified; the
floor now matches the plan's own stated sizing rule.

Also recorded: none of these checks runs in CI, so a future PR touching
release.yml inherits no protection. A persistent guard belongs in its own
issue rather than appended untested here.

No workflow file changed in this commit; docs and evidence only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ViperJuice
ViperJuice deleted the fix/180-package-identity-parsing branch August 26, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant